Adopt registry-backed item occurrences
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "notarius.dnd.item_events",
|
||||
"$id": "notarius.dnd.item_occurrences",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["events"],
|
||||
"required": ["occurrences"],
|
||||
"properties": {
|
||||
"events": {
|
||||
"occurrences": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "kind", "source_refs"],
|
||||
"required": ["item_id", "name", "kind", "source_refs"],
|
||||
"properties": {
|
||||
"item_id": {"type": "string", "minLength": 1},
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"kind": {"type": "string", "enum": ["discovered", "acquired", "lost", "consumed", "transferred"]},
|
||||
"quantity": {"type": "integer", "minimum": 1},
|
||||
@@ -14,25 +14,25 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
SchemaID = "notarius.dnd.item_events"
|
||||
SchemaName = "notarius_dnd_item_events_v1"
|
||||
SchemaID = "notarius.dnd.item_occurrences"
|
||||
SchemaName = "notarius_dnd_item_occurrences_v1"
|
||||
SchemaVersion = "v1"
|
||||
MediaType = "application/json"
|
||||
)
|
||||
|
||||
//go:embed assets/schemas/dnd_item_events.v1.json
|
||||
//go:embed assets/schemas/dnd_item_occurrences.v1.json
|
||||
var schemaAssets embed.FS
|
||||
|
||||
var _ contracts.ArtifactCodec[dnd.ItemEventList] = (*Codec)(nil)
|
||||
var _ contracts.ArtifactCodec[dnd.ItemOccurrenceList] = (*Codec)(nil)
|
||||
|
||||
type Codec struct{}
|
||||
|
||||
func New() *Codec { return &Codec{} }
|
||||
|
||||
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemEventListKind }
|
||||
func (c *Codec) Kind() contracts.ArtifactKind { return dnd.ItemOccurrenceListKind }
|
||||
|
||||
func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_events.v1.json")
|
||||
raw, err := schemaAssets.ReadFile("assets/schemas/dnd_item_occurrences.v1.json")
|
||||
if err != nil {
|
||||
return contracts.ArtifactSchema{}
|
||||
}
|
||||
@@ -46,50 +46,53 @@ func (c *Codec) Schema() contracts.ArtifactSchema {
|
||||
|
||||
func (c *Codec) MediaType() string { return MediaType }
|
||||
|
||||
func (c *Codec) Metadata(value dnd.ItemEventList) map[string]any {
|
||||
return map[string]any{"event_count": len(value.Events)}
|
||||
func (c *Codec) Metadata(value dnd.ItemOccurrenceList) map[string]any {
|
||||
return map[string]any{"occurrence_count": len(value.Occurrences)}
|
||||
}
|
||||
|
||||
func (c *Codec) Encode(value dnd.ItemEventList) ([]byte, error) {
|
||||
func (c *Codec) Encode(value dnd.ItemOccurrenceList) ([]byte, error) {
|
||||
if err := validate(value); err != nil {
|
||||
return nil, fmt.Errorf("encode dnd item event list: %w", err)
|
||||
return nil, fmt.Errorf("encode dnd item occurrence list: %w", err)
|
||||
}
|
||||
return c.EncodeCandidate(value)
|
||||
}
|
||||
|
||||
// EncodeCandidate provides the durable representation before semantic
|
||||
// validators have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.ItemEventList) ([]byte, error) {
|
||||
return candidatejson.EncodeCandidate("dnd item event list", cloneList(value))
|
||||
func (c *Codec) EncodeCandidate(value dnd.ItemOccurrenceList) ([]byte, error) {
|
||||
return candidatejson.EncodeCandidate("dnd item occurrence list", cloneList(value))
|
||||
}
|
||||
|
||||
func (c *Codec) Decode(content []byte) (dnd.ItemEventList, error) {
|
||||
func (c *Codec) Decode(content []byte) (dnd.ItemOccurrenceList, error) {
|
||||
value, err := c.DecodeCandidate(content)
|
||||
if err != nil {
|
||||
return dnd.ItemEventList{}, err
|
||||
return dnd.ItemOccurrenceList{}, err
|
||||
}
|
||||
if err := validate(value); err != nil {
|
||||
return dnd.ItemEventList{}, fmt.Errorf("decode dnd item event list: %w", err)
|
||||
return dnd.ItemOccurrenceList{}, fmt.Errorf("decode dnd item occurrence list: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemEventList, error) {
|
||||
value, err := candidatejson.DecodeCandidate[dnd.ItemEventList]("dnd item event list", content)
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.ItemOccurrenceList, error) {
|
||||
value, err := candidatejson.DecodeCandidate[dnd.ItemOccurrenceList]("dnd item occurrence list", content)
|
||||
if err != nil {
|
||||
return dnd.ItemEventList{}, err
|
||||
return dnd.ItemOccurrenceList{}, err
|
||||
}
|
||||
return cloneList(value), nil
|
||||
}
|
||||
|
||||
func validate(value dnd.ItemEventList) error {
|
||||
if value.Events == nil {
|
||||
func validate(value dnd.ItemOccurrenceList) error {
|
||||
if value.Occurrences == nil {
|
||||
return fmt.Errorf("events must be present")
|
||||
}
|
||||
for index, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", index)
|
||||
for index, event := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
if strings.TrimSpace(event.ItemID) == "" {
|
||||
return fmt.Errorf("%s.item_id must not be empty", prefix)
|
||||
}
|
||||
if strings.TrimSpace(event.Name) == "" {
|
||||
return fmt.Errorf("%s.name must not be empty", prefix)
|
||||
}
|
||||
@@ -127,19 +130,19 @@ func validate(value dnd.ItemEventList) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneList(value dnd.ItemEventList) dnd.ItemEventList {
|
||||
if value.Events == nil {
|
||||
return dnd.ItemEventList{}
|
||||
func cloneList(value dnd.ItemOccurrenceList) dnd.ItemOccurrenceList {
|
||||
if value.Occurrences == nil {
|
||||
return dnd.ItemOccurrenceList{}
|
||||
}
|
||||
cloned := dnd.ItemEventList{Events: make([]dnd.ItemEvent, len(value.Events))}
|
||||
for index, event := range value.Events {
|
||||
cloned.Events[index] = event
|
||||
cloned := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, len(value.Occurrences))}
|
||||
for index, event := range value.Occurrences {
|
||||
cloned.Occurrences[index] = event
|
||||
if event.Quantity != nil {
|
||||
quantity := *event.Quantity
|
||||
cloned.Events[index].Quantity = &quantity
|
||||
cloned.Occurrences[index].Quantity = &quantity
|
||||
}
|
||||
if event.SourceRefs != nil {
|
||||
cloned.Events[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
|
||||
cloned.Occurrences[index].SourceRefs = append([]source.SourceRef(nil), event.SourceRefs...)
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
|
||||
@@ -14,14 +14,13 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func validList() dnd.ItemEventList {
|
||||
func validList() dnd.ItemOccurrenceList {
|
||||
quantity := 12
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: "Hidden Cache", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)},
|
||||
{Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)},
|
||||
{Name: "Torch", Kind: dnd.ItemEventKindLost, From: "party", SourceRefs: refs(3, 3)},
|
||||
{Name: "Healing Potion", Kind: dnd.ItemEventKindConsumed, From: "party", SourceRefs: refs(4, 4)},
|
||||
{Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Hidden Cache", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)},
|
||||
{ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: "party", SourceRefs: refs(2, 2)},
|
||||
{ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: "party", SourceRefs: refs(3, 3)},
|
||||
{ItemID: "item", Name: "Healing Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: "party", SourceRefs: refs(4, 4)},
|
||||
{ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -42,18 +41,18 @@ func TestCodecRoundTripAndIdentities(t *testing.T) {
|
||||
}
|
||||
|
||||
schema := codec.Schema()
|
||||
if codec.Kind() != dnd.ItemEventListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
||||
if codec.Kind() != dnd.ItemOccurrenceListKind || codec.MediaType() != MediaType || schema.ID != SchemaID || schema.Name != SchemaName || schema.Version != SchemaVersion || !json.Valid(schema.JSONSchema) {
|
||||
t.Fatalf("codec identity/schema = %q/%q %#v", codec.Kind(), codec.MediaType(), schema)
|
||||
}
|
||||
registry := pipeline.NewArtifactCodecRegistry()
|
||||
if err := pipeline.RegisterArtifactCodec(registry, codec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec, ok := registry.Spec(dnd.ItemEventListKind)
|
||||
spec, ok := registry.Spec(dnd.ItemOccurrenceListKind)
|
||||
if !ok || spec.SchemaDigest != contracts.DigestArtifactSchema(schema) {
|
||||
t.Fatalf("registered spec = %#v, %t", spec, ok)
|
||||
}
|
||||
if _, err := registry.Encode(dnd.ItemEventListKind, dnd.NPCRegistry{}); err == nil {
|
||||
if _, err := registry.Encode(dnd.ItemOccurrenceListKind, dnd.NPCRegistry{}); err == nil {
|
||||
t.Fatal("Encode() error = nil, want exact type rejection")
|
||||
} else {
|
||||
var typeErr *pipeline.ArtifactCodecTypeError
|
||||
@@ -65,13 +64,13 @@ func TestCodecRoundTripAndIdentities(t *testing.T) {
|
||||
|
||||
func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
|
||||
codec := New()
|
||||
empty := dnd.ItemEventList{Events: []dnd.ItemEvent{}}
|
||||
if content, err := codec.Encode(empty); err != nil || string(content) != `{"events":[]}` {
|
||||
empty := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}
|
||||
if content, err := codec.Encode(empty); err != nil || string(content) != `{"occurrences":[]}` {
|
||||
t.Fatalf("Encode() = %s, %v", content, err)
|
||||
}
|
||||
zero := 0
|
||||
candidate := dnd.ItemEventList{Events: []dnd.ItemEvent{{
|
||||
Name: " ", Kind: dnd.ItemEventKindTransferred, Quantity: &zero, From: "party", To: "Party",
|
||||
candidate := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
|
||||
Name: " ", Kind: dnd.ItemOccurrenceKindTransferred, Quantity: &zero, From: "party", To: "Party",
|
||||
SourceRefs: []source.SourceRef{{SourceID: "", StartUnitID: 0, EndUnitID: -1}},
|
||||
}}}
|
||||
content, err := codec.EncodeCandidate(candidate)
|
||||
@@ -88,15 +87,15 @@ func TestCodecSupportsEmptyListAndPreservesInvalidCandidates(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCodecRejectsStrictJSONAndApprovedBoundaries(t *testing.T) {
|
||||
validJSON := `{"events":[{"name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
validJSON := `{"occurrences":[{"item_id":"ring","name":"Ring","kind":"acquired","to":"party","source_refs":[{"source_id":"session","start_unit_id":1,"end_unit_id":1}]}]}`
|
||||
tests := []struct {
|
||||
name, raw, want string
|
||||
}{
|
||||
{"malformed", `{`, "decode dnd item event list"},
|
||||
{"unknown top level", `{"events":[],"unexpected":true}`, "unknown field"},
|
||||
{"malformed", `{`, "decode dnd item occurrence list"},
|
||||
{"unknown top level", `{"occurrences":[],"unexpected":true}`, "unknown field"},
|
||||
{"unknown event field", strings.Replace(validJSON, `"to":"party"`, `"to":"party","unexpected":true`, 1), "unknown field"},
|
||||
{"unknown reference field", strings.Replace(validJSON, `"end_unit_id":1`, `"end_unit_id":1,"unexpected":true`, 1), "unknown field"},
|
||||
{"trailing", `{"events":[]} {}`, "multiple JSON values"},
|
||||
{"trailing", `{"occurrences":[]} {}`, "multiple JSON values"},
|
||||
{"missing list", `{}`, "events must be present"},
|
||||
{"zero quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":0,"to":"party"`, 1), "quantity must be positive"},
|
||||
{"negative quantity", strings.Replace(validJSON, `"to":"party"`, `"quantity":-1,"to":"party"`, 1), "quantity must be positive"},
|
||||
@@ -125,13 +124,13 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if decoded.Events[1].Quantity == value.Events[1].Quantity || &decoded.Events[1].SourceRefs[0] == &value.Events[1].SourceRefs[0] {
|
||||
if decoded.Occurrences[1].Quantity == value.Occurrences[1].Quantity || &decoded.Occurrences[1].SourceRefs[0] == &value.Occurrences[1].SourceRefs[0] {
|
||||
t.Fatal("DecodeCandidate() retained caller-owned event fields")
|
||||
}
|
||||
*decoded.Events[1].Quantity = 99
|
||||
decoded.Events[1].SourceRefs[0].SourceID = "changed"
|
||||
if *value.Events[1].Quantity != 12 || value.Events[1].SourceRefs[0].SourceID != "session" {
|
||||
t.Fatal("decoded item event aliases input")
|
||||
*decoded.Occurrences[1].Quantity = 99
|
||||
decoded.Occurrences[1].SourceRefs[0].SourceID = "changed"
|
||||
if *value.Occurrences[1].Quantity != 12 || value.Occurrences[1].SourceRefs[0].SourceID != "session" {
|
||||
t.Fatal("decoded item occurrence aliases input")
|
||||
}
|
||||
|
||||
first := codec.Schema()
|
||||
@@ -141,7 +140,7 @@ func TestCodecDeepCopiesBoundaryValuesAndMetadata(t *testing.T) {
|
||||
}
|
||||
metadata := codec.Metadata(value)
|
||||
metadata["payload"] = bytes.Repeat([]byte("x"), 10)
|
||||
if next := codec.Metadata(value); len(next) != 1 || next["event_count"] != len(value.Events) {
|
||||
if next := codec.Metadata(value); len(next) != 1 || next["occurrence_count"] != len(value.Occurrences) {
|
||||
t.Fatalf("Metadata() = %#v", next)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
type orderedItemEventResponse struct {
|
||||
value itemEventResponse
|
||||
type orderedItemOccurrenceResponse struct {
|
||||
value itemOccurrenceResponse
|
||||
earliest int
|
||||
hasEvidence bool
|
||||
}
|
||||
@@ -18,11 +19,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
ordered := make([]orderedItemEventResponse, len(response.Events))
|
||||
for index := range response.Events {
|
||||
earliest, hasEvidence := canonicalizeItemEvent(&response.Events[index], order, sourceID)
|
||||
ordered[index] = orderedItemEventResponse{
|
||||
value: response.Events[index],
|
||||
ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences))
|
||||
for index := range response.Occurrences {
|
||||
earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID)
|
||||
ordered[index] = orderedItemOccurrenceResponse{
|
||||
value: response.Occurrences[index],
|
||||
earliest: earliest,
|
||||
hasEvidence: hasEvidence,
|
||||
}
|
||||
@@ -37,38 +38,43 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
||||
return ordered[left].earliest < ordered[right].earliest
|
||||
})
|
||||
for index := range ordered {
|
||||
response.Events[index] = ordered[index].value
|
||||
response.Occurrences[index] = ordered[index].value
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalizeItemEvent(event *itemEventResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||
func canonicalizeItemOccurrence(event *itemOccurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||
if event == nil {
|
||||
return 0, false
|
||||
}
|
||||
refs := order.Canonicalize(itemEventSourceRefs(event.SourceRefs, sourceID))
|
||||
event.SourceRefs = itemEventResponseRefs(refs)
|
||||
refs := order.Canonicalize(itemOccurrenceSourceRefs(event.SourceRefs, sourceID))
|
||||
event.SourceRefs = itemOccurrenceResponseRefs(refs)
|
||||
return order.EarliestValid(refs)
|
||||
}
|
||||
|
||||
func canonicalItemEventList(response extractionResponse, sourceID string) dnd.ItemEventList {
|
||||
if response.Events == nil {
|
||||
return dnd.ItemEventList{}
|
||||
func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) dnd.ItemOccurrenceList {
|
||||
if response.Occurrences == nil {
|
||||
return dnd.ItemOccurrenceList{}
|
||||
}
|
||||
events := make([]dnd.ItemEvent, len(response.Events))
|
||||
for index, event := range response.Events {
|
||||
events[index] = dnd.ItemEvent{
|
||||
occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences))
|
||||
for _, event := range response.Occurrences {
|
||||
item, found := registry.LookupID(event.ItemID)
|
||||
if !found || item.Name != event.Name {
|
||||
continue
|
||||
}
|
||||
occurrences = append(occurrences, dnd.ItemOccurrence{
|
||||
ItemID: event.ItemID,
|
||||
Name: event.Name,
|
||||
Kind: dnd.ItemEventKind(event.Kind),
|
||||
Kind: dnd.ItemOccurrenceKind(event.Kind),
|
||||
Quantity: cloneQuantity(event.Quantity),
|
||||
From: event.From,
|
||||
To: event.To,
|
||||
SourceRefs: itemEventSourceRefs(event.SourceRefs, sourceID),
|
||||
}
|
||||
SourceRefs: itemOccurrenceSourceRefs(event.SourceRefs, sourceID),
|
||||
})
|
||||
}
|
||||
return dnd.ItemEventList{Events: events}
|
||||
return dnd.ItemOccurrenceList{Occurrences: occurrences}
|
||||
}
|
||||
|
||||
func itemEventSourceRefs(refs []itemEventSourceRefResponse, sourceID string) []source.SourceRef {
|
||||
func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
||||
if refs == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -79,13 +85,13 @@ func itemEventSourceRefs(refs []itemEventSourceRefResponse, sourceID string) []s
|
||||
return values
|
||||
}
|
||||
|
||||
func itemEventResponseRefs(refs []source.SourceRef) []itemEventSourceRefResponse {
|
||||
func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse {
|
||||
if refs == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]itemEventSourceRefResponse, len(refs))
|
||||
values := make([]itemOccurrenceSourceRefResponse, len(refs))
|
||||
for index, ref := range refs {
|
||||
values[index] = itemEventSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID}
|
||||
values[index] = itemOccurrenceSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
// Package itemevents extracts source-grounded D&D item events.
|
||||
// Package itemevents extracts source-grounded D&D item occurrences.
|
||||
package itemevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
const Key = "dnd/item-events"
|
||||
|
||||
const (
|
||||
ItemRegistryReferenceSlot = itemregistry.ReferenceSlot
|
||||
ItemRegistryMaxBytes = itemregistry.MaxBytes
|
||||
)
|
||||
|
||||
const mappingPolicy = "dnd.item_events.extract_mapping.v1"
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
@@ -32,10 +39,20 @@ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
|
||||
}
|
||||
|
||||
func referenceSlots() []contracts.ReferenceSlot {
|
||||
return shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
slots := shared.ReferenceSlots(referenceSlotDescriptions)
|
||||
slots = append(slots, contracts.ReferenceSlot{
|
||||
Name: ItemRegistryReferenceSlot,
|
||||
Description: "Required normalized item registry used only for item identity grounding, never as occurrence evidence.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{"application/json"},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.ItemRegistryKind},
|
||||
MaxBytes: ItemRegistryMaxBytes,
|
||||
})
|
||||
sort.Slice(slots, func(left, right int) bool { return slots[left].Name < slots[right].Name })
|
||||
return slots
|
||||
}
|
||||
|
||||
var _ contracts.Extractor[dnd.ItemEventList] = (*Extractor)(nil)
|
||||
var _ contracts.Extractor[dnd.ItemOccurrenceList] = (*Extractor)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Extractor)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Extractor)(nil)
|
||||
|
||||
@@ -43,6 +60,7 @@ type Options struct{}
|
||||
|
||||
type Extractor struct {
|
||||
llm contracts.StructuredLLMClient
|
||||
itemResolver *itemregistry.Resolver
|
||||
promptSHA string
|
||||
responseSchemaSHA string
|
||||
}
|
||||
@@ -54,6 +72,14 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
|
||||
if len(references) > 1 {
|
||||
return nil, extractorErrorf("at most one reference set may be supplied")
|
||||
}
|
||||
var referenceSet contracts.ReferenceSet
|
||||
if len(references) == 1 {
|
||||
referenceSet = references[0]
|
||||
}
|
||||
itemResolver, err := itemregistry.NewResolver(referenceSet)
|
||||
if err != nil {
|
||||
return nil, extractorErrorf("prepare item registry prompt input: %w", err)
|
||||
}
|
||||
promptSHA, err := promptAssetMetadata()
|
||||
if err != nil {
|
||||
return nil, extractorErrorf("load prompt metadata: %w", err)
|
||||
@@ -62,7 +88,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
|
||||
if err != nil {
|
||||
return nil, extractorErrorf("load response schema: %w", err)
|
||||
}
|
||||
return &Extractor{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
|
||||
return &Extractor{llm: llmClient, itemResolver: itemResolver, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
|
||||
}
|
||||
|
||||
func (e *Extractor) Key() string { return Key }
|
||||
@@ -73,7 +99,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
metadata := map[string]any{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": SchemaVersion,
|
||||
"prompt_sha256": e.promptSHA,
|
||||
@@ -84,6 +110,12 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
"response_schema_sha256": e.responseSchemaSHA,
|
||||
"mapping_policy": mappingPolicy,
|
||||
}
|
||||
seeded := e.itemResolver.Seeded()
|
||||
if seeded.Bound() {
|
||||
metadata["item_registry_digest"] = seeded.Digest()
|
||||
metadata["item_count"] = seeded.Count()
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
@@ -94,35 +126,41 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "prompt", Value: e.promptSHA},
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
{Name: "mapping_policy", Value: mappingPolicy},
|
||||
{Name: "item_registry", Value: e.itemResolver.Seeded().ProjectionDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemEventList], error) {
|
||||
func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.ItemOccurrenceList], error) {
|
||||
if e == nil {
|
||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("extractor must not be nil")
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("extractor must not be nil")
|
||||
}
|
||||
if e.llm == nil {
|
||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("LLM client must not be nil")
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("%w", err)
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
|
||||
registry, err := e.itemResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("resolve item registry: %w", err)
|
||||
}
|
||||
if !registry.Bound() {
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("item registry reference is required")
|
||||
}
|
||||
var response extractionResponse
|
||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||
inputs[ItemRegistryReferenceSlot] = registry.PromptInput()
|
||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key,
|
||||
PromptID: PromptID,
|
||||
PromptVersion: SchemaVersion,
|
||||
ProfileID: req.LLMProfile,
|
||||
SessionID: req.SessionID,
|
||||
Inputs: shared.PromptInputs(sourceInput, req.References),
|
||||
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
|
||||
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
|
||||
}, &response); err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{}, extractorErrorf("complete structured output: %w", err)
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||
}
|
||||
canonicalizeResponse(&response, order, req.Source.ID)
|
||||
return contracts.TypedExtractionResult[dnd.ItemEventList]{Value: canonicalItemEventList(response, req.Source.ID)}, nil
|
||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID, registry)}, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
@@ -132,13 +170,13 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
ExecutionClass: contracts.ExecutionClassLLMBacked,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.ItemEventListKind,
|
||||
ArtifactKind: dnd.ItemOccurrenceListKind,
|
||||
ReferenceSlots: referenceSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ExtractorRegistry) error {
|
||||
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterExtractorBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Extractor[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -160,5 +198,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
}
|
||||
|
||||
func extractorErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd item events extractor: "+format, args...)
|
||||
return fmt.Errorf("dnd item occurrences extractor: "+format, args...)
|
||||
}
|
||||
|
||||
@@ -2,161 +2,61 @@ package itemevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||
)
|
||||
|
||||
func TestExtractMapsAllEventKindsAndCanonicalizesEvidence(t *testing.T) {
|
||||
quantity := 20
|
||||
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{
|
||||
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(5, 5)},
|
||||
{Name: "Moonblade", Kind: "transferred", From: "Aria", To: "Borin", SourceRefs: responseRefs(3, 3)},
|
||||
{Name: "Hidden Cache", Kind: "discovered", SourceRefs: []itemEventSourceRefResponse{{StartSegment: 1, EndSegment: 1}, {StartSegment: 1, EndSegment: 1}}},
|
||||
{Name: "Gold Pieces", Kind: "acquired", Quantity: &quantity, To: "party", SourceRefs: responseRefs(2, 2)},
|
||||
{Name: "Healing Potion", Kind: "consumed", From: "party", SourceRefs: responseRefs(4, 4)},
|
||||
}}}
|
||||
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v", err)
|
||||
}
|
||||
if got := []dnd.ItemEventKind{result.Value.Events[0].Kind, result.Value.Events[1].Kind, result.Value.Events[2].Kind, result.Value.Events[3].Kind, result.Value.Events[4].Kind}; !reflect.DeepEqual(got, []dnd.ItemEventKind{
|
||||
dnd.ItemEventKindDiscovered, dnd.ItemEventKindAcquired, dnd.ItemEventKindTransferred, dnd.ItemEventKindConsumed, dnd.ItemEventKindLost,
|
||||
}) {
|
||||
t.Fatalf("event kinds = %#v, want source order", got)
|
||||
}
|
||||
acquired := result.Value.Events[1]
|
||||
if acquired.Name != "Gold Pieces" || acquired.To != "party" || acquired.Quantity == nil || *acquired.Quantity != 20 {
|
||||
t.Fatalf("acquired event = %#v", acquired)
|
||||
}
|
||||
if result.Value.Events[0].Quantity != nil || result.Value.Events[0].From != "" || result.Value.Events[0].To != "" {
|
||||
t.Fatalf("discovered event = %#v, want omitted optional fields", result.Value.Events[0])
|
||||
}
|
||||
if refs := result.Value.Events[0].SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}) {
|
||||
t.Fatalf("source refs = %#v, want source ID attachment and deduplication", refs)
|
||||
}
|
||||
if acquired.Quantity == client.response.Events[3].Quantity {
|
||||
t.Fatal("mapped quantity aliases private response")
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
||||
}
|
||||
request := client.requests[0]
|
||||
if request.StageName != Key || request.PromptID != PromptID || request.PromptVersion != SchemaVersion || request.ProfileID != "profile-item-events" || request.SessionID != "session-123" {
|
||||
t.Fatalf("LLM request identity = %#v", request)
|
||||
}
|
||||
if transcript := request.Inputs["transcript"]; string(transcript.Content) != string(extractionRequest().Chunk.Content) || transcript.Name != "transcript" {
|
||||
t.Fatalf("transcript input = %#v", transcript)
|
||||
}
|
||||
for _, name := range []string{"players", "party", "glossary"} {
|
||||
if input := request.Inputs[name]; string(input.Content) != " " {
|
||||
t.Fatalf("absent %s input = %#v, want retained empty prompt material", name, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPreservesInvalidCandidatesAndEmptyResults(t *testing.T) {
|
||||
client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"","kind":"transferred","quantity":0,"from":"party","to":"Party","source_refs":[{"start_segment":99,"end_segment":-1}]}]}`)}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v", err)
|
||||
}
|
||||
event := result.Value.Events[0]
|
||||
if event.Name != "" || event.Kind != "transferred" || event.Quantity == nil || *event.Quantity != 0 || event.From != "party" || event.To != "Party" {
|
||||
t.Fatalf("invalid candidate = %#v, want values preserved", event)
|
||||
}
|
||||
if refs := event.SourceRefs; len(refs) != 1 || refs[0] != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 99, EndUnitID: -1}) {
|
||||
t.Fatalf("source refs = %#v, want invalid range preserved", refs)
|
||||
}
|
||||
|
||||
empty, err := newExtractor(t, &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}}).Extract(context.Background(), extractionRequest())
|
||||
if err != nil || empty.Value.Events == nil || len(empty.Value.Events) != 0 {
|
||||
t.Fatalf("empty provider result = %#v, %v; want valid empty list", empty.Value, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMapsNullableResponseFieldsToAbsentArtifactFields(t *testing.T) {
|
||||
client := &fakeItemEventsLLMClient{content: []byte(`{"events":[{"name":"Hidden Cache","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Value.Events) != 1 {
|
||||
t.Fatalf("events = %#v, want one", result.Value.Events)
|
||||
}
|
||||
event := result.Value.Events[0]
|
||||
if event.Quantity != nil || event.From != "" || event.To != "" {
|
||||
t.Fatalf("nullable response fields mapped to artifact values: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUsesSourceDocumentOrderForCandidates(t *testing.T) {
|
||||
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{
|
||||
{Name: "Later", Kind: "discovered", SourceRefs: responseRefs(10, 10)},
|
||||
{Name: "Earlier", Kind: "discovered", SourceRefs: responseRefs(100, 100)},
|
||||
func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
||||
id := itemidentity.DeriveID("Torch")
|
||||
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
||||
{ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
||||
{ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
|
||||
{ItemID: "torch", Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(3, 3)},
|
||||
}}}
|
||||
req := extractionRequest()
|
||||
req.Source.Units = []source.SourceUnit{{ID: 100}, {ID: 10}}
|
||||
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
||||
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 100, EndUnitID: 10}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
||||
req.References = itemRegistryReferences(t)
|
||||
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := []string{result.Value.Events[0].Name, result.Value.Events[1].Name}; !reflect.DeepEqual(got, []string{"Earlier", "Later"}) {
|
||||
t.Fatalf("event order = %#v, want source document order", got)
|
||||
if len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].ItemID != id || result.Value.Occurrences[0].Name != "Torch" {
|
||||
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
||||
}
|
||||
input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
|
||||
if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" {
|
||||
t.Fatalf("registry prompt input = %#v", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRequiresItemRegistry(t *testing.T) {
|
||||
_, err := newExtractor(t, &fakeItemOccurrencesLLMClient{}).Extract(context.Background(), extractionRequest())
|
||||
if err == nil {
|
||||
t.Fatal("Extract() error = nil, want required registry error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractPreservesNullableFields(t *testing.T) {
|
||||
id := itemidentity.DeriveID("Torch")
|
||||
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"item_id":"` + id + `","name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
||||
req := extractionRequest()
|
||||
req.References = itemRegistryReferences(t)
|
||||
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||
if err != nil || len(result.Value.Occurrences) != 1 || result.Value.Occurrences[0].Quantity != nil || result.Value.Occurrences[0].From != "" || result.Value.Occurrences[0].To != "" {
|
||||
t.Fatalf("result = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractUsesOnlySupportedPromptInputs(t *testing.T) {
|
||||
client := &fakeItemEventsLLMClient{response: extractionResponse{Events: []itemEventResponse{}}}
|
||||
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}}
|
||||
req := extractionRequest()
|
||||
req.References = contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"players": {Slot: contracts.ReferenceSlot{Name: "players"}, Items: []contracts.ReferenceItem{{SlotName: "players", Content: []byte("Dana: Aria")}}},
|
||||
"party": {Slot: contracts.ReferenceSlot{Name: "party"}, Items: []contracts.ReferenceItem{{SlotName: "party", Content: []byte("Aria: ranger")}}},
|
||||
"glossary": {Slot: contracts.ReferenceSlot{Name: "glossary"}, Items: []contracts.ReferenceItem{{SlotName: "glossary", Content: []byte("Moonblade: heirloom")}}},
|
||||
"npcs": {Slot: contracts.ReferenceSlot{Name: "npc_registry"}, Items: []contracts.ReferenceItem{{SlotName: "npc_registry", Content: []byte("must not be used")}}},
|
||||
}}
|
||||
if _, err := newExtractor(t, client).Extract(context.Background(), req); err != nil {
|
||||
req.References = itemRegistryReferences(t)
|
||||
req.References.Slots["unrelated"] = contracts.ResolvedReferenceSlot{Slot: contracts.ReferenceSlot{Name: "unrelated"}, Items: []contracts.ReferenceItem{{SlotName: "unrelated", Content: []byte("ignored")}}}
|
||||
if _, err := newExtractor(t, client, req.References).Extract(context.Background(), req); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inputs := client.requests[0].Inputs
|
||||
if string(inputs["players"].Content) != "Dana: Aria" || string(inputs["party"].Content) != "Aria: ranger" || string(inputs["glossary"].Content) != "Moonblade: heirloom" {
|
||||
t.Fatalf("reference inputs = %#v", inputs)
|
||||
}
|
||||
if _, ok := inputs["npcs"]; ok {
|
||||
t.Fatalf("unexpected generated-lane input: %#v", inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractRetainsLocalErrorContext(t *testing.T) {
|
||||
request := extractionRequest()
|
||||
extractor := newExtractor(t, &fakeItemEventsLLMClient{})
|
||||
var nilExtractor *Extractor
|
||||
request.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
extractor *Extractor
|
||||
req contracts.TypedExtractionRequest
|
||||
want string
|
||||
}{
|
||||
{"nil extractor", nilExtractor, extractionRequest(), "extractor"},
|
||||
{"nil client", &Extractor{}, extractionRequest(), "LLM client"},
|
||||
{"preflight", extractor, request, "must match chunk"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd item events") || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Extract() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := newExtractor(t, &fakeItemEventsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), extractionRequest()); err == nil || !strings.Contains(err.Error(), "provider unavailable") {
|
||||
t.Fatalf("provider error = %v", err)
|
||||
if _, ok := client.requests[0].Inputs["unrelated"]; ok {
|
||||
t.Fatalf("unexpected prompt input: %#v", client.requests[0].Inputs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package itemevents
|
||||
|
||||
type extractionResponse struct {
|
||||
Events []itemEventResponse `json:"events"`
|
||||
Occurrences []itemOccurrenceResponse `json:"occurrences"`
|
||||
}
|
||||
|
||||
type itemEventResponse struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Quantity *int `json:"quantity,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
SourceRefs []itemEventSourceRefResponse `json:"source_refs"`
|
||||
type itemOccurrenceResponse struct {
|
||||
ItemID string `json:"item_id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Quantity *int `json:"quantity,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
SourceRefs []itemOccurrenceSourceRefResponse `json:"source_refs"`
|
||||
}
|
||||
|
||||
type itemEventSourceRefResponse struct {
|
||||
type itemOccurrenceSourceRefResponse struct {
|
||||
StartSegment int `json:"start_segment"`
|
||||
EndSegment int `json:"end_segment"`
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ var promptAssetManifest = shared.PromptAssetManifest{
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
|
||||
{Name: "instructions.md", Path: "prompts/instructions.md"},
|
||||
{Name: "item-registry.md", Path: "prompts/item-registry.md"},
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-system.md",
|
||||
@@ -43,7 +44,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
}
|
||||
promptFS, err := promptAssetManifest.PromptFS(assets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare item event prompt assets: %w", err)
|
||||
return fmt.Errorf("prepare item occurrence prompt assets: %w", err)
|
||||
}
|
||||
if err := registry.RegisterPromptFS(promptFS, promptAssetRoot); err != nil {
|
||||
return err
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/promptkit"
|
||||
)
|
||||
|
||||
func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) {
|
||||
func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -30,16 +30,17 @@ func TestPromptAssetsPrepareItemEventPrompt(t *testing.T) {
|
||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "item-events-test-profile",
|
||||
Inputs: map[string]promptkit.ArtifactRef{
|
||||
"transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`),
|
||||
"players": promptkit.Inline("item-event-player"),
|
||||
"party": promptkit.Inline(" "),
|
||||
"glossary": promptkit.Inline(" "),
|
||||
"transcript": promptkit.InlineWithURI("file:///session.json", `{"units":[{"sentinel":"item-event-transcript"}]}`),
|
||||
"players": promptkit.Inline("item-event-player"),
|
||||
"party": promptkit.Inline(" "),
|
||||
"glossary": promptkit.Inline(" "),
|
||||
"item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_events_llm.v1.json" {
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_item_occurrences_llm.v1.json" {
|
||||
t.Fatalf("prepared prompt = %#v", prepared)
|
||||
}
|
||||
}
|
||||
@@ -49,12 +50,12 @@ func TestPromptAssetsDoNotLeakIntoMetadata(t *testing.T) {
|
||||
if err != nil || !strings.HasPrefix(hash, "sha256:") {
|
||||
t.Fatalf("promptAssetMetadata() = %q, %v", hash, err)
|
||||
}
|
||||
metadata := newExtractor(t, &fakeItemEventsLLMClient{}).ManifestMetadata()
|
||||
metadata := newExtractor(t, &fakeItemOccurrencesLLMClient{}).ManifestMetadata()
|
||||
payload, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, forbidden := range []string{"common-dnd-system", "dnd_item_events_llm.v1.json"} {
|
||||
for _, forbidden := range []string{"common-dnd-system", "dnd_item_occurrences_llm.v1.json"} {
|
||||
if strings.Contains(string(payload), forbidden) {
|
||||
t.Fatalf("metadata leaked raw asset content %q: %s", forbidden, payload)
|
||||
}
|
||||
|
||||
@@ -2,64 +2,42 @@ package itemevents
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
|
||||
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||
)
|
||||
|
||||
func TestConstructorSpecOptionsAndMetadata(t *testing.T) {
|
||||
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
|
||||
t.Fatalf("New(nil) error = %v", err)
|
||||
func itemRegistryReferences(t *testing.T) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: itemidentity.DeriveID("Torch"), Name: "Torch", SourceRefs: testSourceRefs()}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(&fakeItemEventsLLMClient{}, Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||
}
|
||||
|
||||
func testSourceRefs() []source.SourceRef {
|
||||
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}
|
||||
}
|
||||
|
||||
func TestModuleSpecDeclaresRequiredRegistry(t *testing.T) {
|
||||
spec := ModuleSpec()
|
||||
var slot contracts.ReferenceSlot
|
||||
for _, candidate := range spec.ReferenceSlots {
|
||||
if candidate.Name == ItemRegistryReferenceSlot {
|
||||
slot = candidate
|
||||
}
|
||||
}
|
||||
want := pipeline.ModuleSpec{
|
||||
Key: Key, Stage: pipeline.StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"chunks", "source.transcript"}, Provides: []string{"dnd.item_events"}, ArtifactKind: dnd.ItemEventListKind,
|
||||
ReferenceSlots: []contracts.ReferenceSlot{
|
||||
{Name: "glossary", Description: referenceSlotDescriptions.Glossary, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
||||
{Name: "party", Description: referenceSlotDescriptions.Party, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
||||
{Name: "players", Description: referenceSlotDescriptions.Players, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
||||
{Name: "roster", Description: referenceSlotDescriptions.Roster, AcceptedMediaTypes: []string{"application/json", "application/x-yaml", "application/yaml", "text/markdown", "text/plain"}},
|
||||
},
|
||||
}
|
||||
if got := ModuleSpec(); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ModuleSpec() = %#v, want %#v", got, want)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") {
|
||||
t.Fatalf("DecodeOptions() error = %v", err)
|
||||
if !slot.Required || !reflect.DeepEqual(slot.AcceptedArtifactKinds, []contracts.ArtifactKind{dnd.ItemRegistryKind}) || slot.MaxBytes != ItemRegistryMaxBytes {
|
||||
t.Fatalf("registry slot = %#v", slot)
|
||||
}
|
||||
registry := pipeline.NewExtractorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := registry.Spec(Key); !ok || !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("registry spec = %#v, %t", got, ok)
|
||||
}
|
||||
|
||||
extractor := newExtractor(t, &fakeItemEventsLLMClient{})
|
||||
metadata := extractor.ManifestMetadata()
|
||||
for key, want := range map[string]string{
|
||||
"prompt_id": PromptID, "prompt_version": SchemaVersion, "response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion, "mapping_policy": mappingPolicy,
|
||||
} {
|
||||
if metadata[key] != want {
|
||||
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"prompt_sha256", "response_schema_sha256"} {
|
||||
if value, ok := metadata[key].(string); !ok || !strings.HasPrefix(value, "sha256:") {
|
||||
t.Fatalf("metadata[%q] = %#v", key, metadata[key])
|
||||
}
|
||||
}
|
||||
if got := extractor.CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{
|
||||
{Name: "prompt", Value: metadata["prompt_sha256"].(string)},
|
||||
{Name: "response_schema", Value: metadata["response_schema_sha256"].(string)},
|
||||
{Name: "mapping_policy", Value: mappingPolicy},
|
||||
}) {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
|
||||
const (
|
||||
PromptID = "dnd.item_events"
|
||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_events_llm")
|
||||
ResponseSchemaID = "notarius.dnd.item_events.llm"
|
||||
ResponseSchemaName = "notarius_dnd_item_events_llm_v1"
|
||||
ResponseSchemaKey = llm.ResponseSchemaKey("dnd_item_occurrences_llm")
|
||||
ResponseSchemaID = "notarius.dnd.item_occurrences.llm"
|
||||
ResponseSchemaName = "notarius_dnd_item_occurrences_llm_v1"
|
||||
SchemaVersion = "v1"
|
||||
)
|
||||
|
||||
@@ -20,6 +20,6 @@ func loadResponseSchema() (llm.ResponseSchema, error) {
|
||||
ID: ResponseSchemaID,
|
||||
Version: SchemaVersion,
|
||||
Name: ResponseSchemaName,
|
||||
AssetPath: "schemas/dnd_item_events_llm.v1.json",
|
||||
AssetPath: "schemas/dnd_item_occurrences_llm.v1.json",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,13 +17,13 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
|
||||
t.Fatalf("schema = %#v", schema)
|
||||
}
|
||||
valid := map[string]any{"events": []any{
|
||||
valid := map[string]any{"occurrences": []any{
|
||||
map[string]any{
|
||||
"name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
|
||||
"item_id": "item:sha256:test", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
|
||||
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
|
||||
},
|
||||
map[string]any{
|
||||
"name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
|
||||
"item_id": "item:sha256:test", "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
|
||||
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
|
||||
},
|
||||
}}
|
||||
@@ -38,12 +38,12 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
name string
|
||||
value map[string]any
|
||||
}{
|
||||
{"missing events", map[string]any{}},
|
||||
{"missing event name", map[string]any{"events": []any{withoutField(responseEvent(), "name")}}},
|
||||
{"missing nullable field", map[string]any{"events": []any{withoutField(responseEvent(), "quantity")}}},
|
||||
{"unknown event field", map[string]any{"events": []any{withField(responseEvent(), "extra", true)}}},
|
||||
{"unknown reference field", map[string]any{"events": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
|
||||
{"noninteger range", map[string]any{"events": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
|
||||
{"missing occurrences", map[string]any{}},
|
||||
{"missing event name", map[string]any{"occurrences": []any{withoutField(responseEvent(), "name")}}},
|
||||
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseEvent(), "quantity")}}},
|
||||
{"unknown event field", map[string]any{"occurrences": []any{withField(responseEvent(), "extra", true)}}},
|
||||
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
|
||||
{"noninteger range", map[string]any{"occurrences": []any{withField(responseEvent(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
content, err := json.Marshal(test.value)
|
||||
@@ -68,7 +68,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
||||
|
||||
func responseEvent() map[string]any {
|
||||
return map[string]any{
|
||||
"name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
|
||||
"item_id": "item:sha256:test", "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ func sourceDocument() *source.SourceDocument {
|
||||
}
|
||||
}
|
||||
|
||||
func responseRefs(start, end int) []itemEventSourceRefResponse {
|
||||
return []itemEventSourceRefResponse{{StartSegment: start, EndSegment: end}}
|
||||
func responseRefs(start, end int) []itemOccurrenceSourceRefResponse {
|
||||
return []itemOccurrenceSourceRefResponse{{StartSegment: start, EndSegment: end}}
|
||||
}
|
||||
|
||||
func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references ...contracts.ReferenceSet) *Extractor {
|
||||
@@ -61,14 +61,14 @@ func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest)
|
||||
return req
|
||||
}
|
||||
|
||||
type fakeItemEventsLLMClient struct {
|
||||
type fakeItemOccurrencesLLMClient struct {
|
||||
response extractionResponse
|
||||
content []byte
|
||||
err error
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *fakeItemEventsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
func (client *fakeItemOccurrencesLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
|
||||
if client.err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, client.err
|
||||
|
||||
@@ -14,14 +14,14 @@ import (
|
||||
|
||||
const partyHolder = "party"
|
||||
|
||||
// SupportedKind reports whether kind is one of the durable item-event kinds.
|
||||
func SupportedKind(kind dnd.ItemEventKind) bool {
|
||||
// SupportedKind reports whether kind is one of the durable item occurrence kinds.
|
||||
func SupportedKind(kind dnd.ItemOccurrenceKind) bool {
|
||||
switch kind {
|
||||
case dnd.ItemEventKindDiscovered,
|
||||
dnd.ItemEventKindAcquired,
|
||||
dnd.ItemEventKindLost,
|
||||
dnd.ItemEventKindConsumed,
|
||||
dnd.ItemEventKindTransferred:
|
||||
case dnd.ItemOccurrenceKindDiscovered,
|
||||
dnd.ItemOccurrenceKindAcquired,
|
||||
dnd.ItemOccurrenceKindLost,
|
||||
dnd.ItemOccurrenceKindConsumed,
|
||||
dnd.ItemOccurrenceKindTransferred:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -45,18 +45,18 @@ func IsPartyHolder(value string) bool { return ComparisonKey(value) == partyHold
|
||||
// ValidHolderCombination reports whether the optional holder fields satisfy
|
||||
// the durable rules for kind. Blank holders are treated as absent so callers
|
||||
// can preserve invalid extraction candidates for their owning validators.
|
||||
func ValidHolderCombination(kind dnd.ItemEventKind, from, to string) bool {
|
||||
func ValidHolderCombination(kind dnd.ItemOccurrenceKind, from, to string) bool {
|
||||
hasFrom := HolderPresent(from)
|
||||
hasTo := HolderPresent(to)
|
||||
|
||||
switch kind {
|
||||
case dnd.ItemEventKindDiscovered:
|
||||
case dnd.ItemOccurrenceKindDiscovered:
|
||||
return !hasFrom && !hasTo
|
||||
case dnd.ItemEventKindAcquired:
|
||||
case dnd.ItemOccurrenceKindAcquired:
|
||||
return !hasFrom && hasTo
|
||||
case dnd.ItemEventKindLost, dnd.ItemEventKindConsumed:
|
||||
case dnd.ItemOccurrenceKindLost, dnd.ItemOccurrenceKindConsumed:
|
||||
return hasFrom && !hasTo
|
||||
case dnd.ItemEventKindTransferred:
|
||||
case dnd.ItemOccurrenceKindTransferred:
|
||||
return hasFrom && hasTo && !IsPartyHolder(from) && !IsPartyHolder(to) && ComparisonKey(from) != ComparisonKey(to)
|
||||
default:
|
||||
return false
|
||||
@@ -93,7 +93,7 @@ func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
||||
// Less defines the canonical event order. Invalid source references remain
|
||||
// comparable through SourceRefOrder's literal fallback so malformed candidates
|
||||
// are still safe to sort and diagnose.
|
||||
func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
||||
func Less(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
||||
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
||||
if leftHasEvidence != rightHasEvidence {
|
||||
@@ -102,6 +102,9 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
||||
if leftHasEvidence && leftPosition != rightPosition {
|
||||
return leftPosition < rightPosition
|
||||
}
|
||||
if left.ItemID != right.ItemID {
|
||||
return left.ItemID < right.ItemID
|
||||
}
|
||||
if leftKey, rightKey := ComparisonKey(left.Name), ComparisonKey(right.Name); leftKey != rightKey {
|
||||
return leftKey < rightKey
|
||||
}
|
||||
@@ -125,8 +128,8 @@ func Less(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
||||
|
||||
// ExactEqual reports whether events are exact duplicates after their display
|
||||
// fields and evidence have been canonicalized for the supplied source order.
|
||||
func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
||||
if DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind ||
|
||||
func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemOccurrence) bool {
|
||||
if left.ItemID != right.ItemID || DisplayValue(left.Name) != DisplayValue(right.Name) || left.Kind != right.Kind ||
|
||||
DisplayValue(left.From) != DisplayValue(right.From) || DisplayValue(left.To) != DisplayValue(right.To) ||
|
||||
(left.Quantity == nil) != (right.Quantity == nil) {
|
||||
return false
|
||||
@@ -140,8 +143,9 @@ func ExactEqual(order shared.SourceRefOrder, left, right dnd.ItemEvent) bool {
|
||||
// ExactIdentity returns a collision-safe duplicate key after display and
|
||||
// evidence canonicalization. It is intended for callers that have already
|
||||
// decided the event is eligible for duplicate handling.
|
||||
func ExactIdentity(order shared.SourceRefOrder, event dnd.ItemEvent) string {
|
||||
func ExactIdentity(order shared.SourceRefOrder, event dnd.ItemOccurrence) string {
|
||||
var key strings.Builder
|
||||
writeKeyString(&key, event.ItemID)
|
||||
writeKeyString(&key, DisplayValue(event.Name))
|
||||
writeKeyString(&key, string(event.Kind))
|
||||
writeKeyString(&key, DisplayValue(event.From))
|
||||
|
||||
@@ -11,14 +11,14 @@ import (
|
||||
|
||||
func TestValidHolderCombination(t *testing.T) {
|
||||
valid := []struct {
|
||||
kind dnd.ItemEventKind
|
||||
kind dnd.ItemOccurrenceKind
|
||||
from, to string
|
||||
}{
|
||||
{dnd.ItemEventKindDiscovered, "", ""},
|
||||
{dnd.ItemEventKindAcquired, "", "party"},
|
||||
{dnd.ItemEventKindLost, "party", ""},
|
||||
{dnd.ItemEventKindConsumed, "party", ""},
|
||||
{dnd.ItemEventKindTransferred, "Aria", "Borin"},
|
||||
{dnd.ItemOccurrenceKindDiscovered, "", ""},
|
||||
{dnd.ItemOccurrenceKindAcquired, "", "party"},
|
||||
{dnd.ItemOccurrenceKindLost, "party", ""},
|
||||
{dnd.ItemOccurrenceKindConsumed, "party", ""},
|
||||
{dnd.ItemOccurrenceKindTransferred, "Aria", "Borin"},
|
||||
}
|
||||
for _, test := range valid {
|
||||
if !ValidHolderCombination(test.kind, test.from, test.to) {
|
||||
@@ -27,17 +27,17 @@ func TestValidHolderCombination(t *testing.T) {
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
kind dnd.ItemEventKind
|
||||
kind dnd.ItemOccurrenceKind
|
||||
from, to string
|
||||
}{
|
||||
{dnd.ItemEventKindDiscovered, "Aria", ""},
|
||||
{dnd.ItemEventKindAcquired, "", ""},
|
||||
{dnd.ItemEventKindLost, "", ""},
|
||||
{dnd.ItemEventKindConsumed, "", "Borin"},
|
||||
{dnd.ItemEventKindTransferred, "party", "Borin"},
|
||||
{dnd.ItemEventKindTransferred, "Aria", "Party"},
|
||||
{dnd.ItemEventKindTransferred, "Aria", "aria"},
|
||||
{dnd.ItemEventKindTransferred, "Åria", "Åria"},
|
||||
{dnd.ItemOccurrenceKindDiscovered, "Aria", ""},
|
||||
{dnd.ItemOccurrenceKindAcquired, "", ""},
|
||||
{dnd.ItemOccurrenceKindLost, "", ""},
|
||||
{dnd.ItemOccurrenceKindConsumed, "", "Borin"},
|
||||
{dnd.ItemOccurrenceKindTransferred, "party", "Borin"},
|
||||
{dnd.ItemOccurrenceKindTransferred, "Aria", "Party"},
|
||||
{dnd.ItemOccurrenceKindTransferred, "Aria", "aria"},
|
||||
{dnd.ItemOccurrenceKindTransferred, "Åria", "Åria"},
|
||||
{"unsupported", "", ""},
|
||||
}
|
||||
for _, test := range invalid {
|
||||
@@ -53,16 +53,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) {
|
||||
return []source.SourceRef{{SourceID: "session", StartUnitID: start, EndUnitID: end}}
|
||||
}
|
||||
quantity := func(value int) *int { return &value }
|
||||
base := dnd.ItemEvent{Name: "Amulet", Kind: dnd.ItemEventKindAcquired, To: "Borin", SourceRefs: ref(20, 20)}
|
||||
base := dnd.ItemOccurrence{ItemID: "item", Name: "Amulet", Kind: dnd.ItemOccurrenceKindAcquired, To: "Borin", SourceRefs: ref(20, 20)}
|
||||
tests := []struct {
|
||||
name string
|
||||
left, right dnd.ItemEvent
|
||||
left, right dnd.ItemOccurrence
|
||||
}{
|
||||
{"earlier evidence", withRefs(base, ref(10, 10)), base},
|
||||
{"valid evidence before malformed", base, withRefs(base, ref(999, 999))},
|
||||
{"normalized name", withName(base, "Amulet"), withName(base, "Blade")},
|
||||
{"exact trimmed name", withName(base, "Amulet"), withName(base, "amulet")},
|
||||
{"kind", withKind(base, dnd.ItemEventKindAcquired), withKind(base, dnd.ItemEventKindLost)},
|
||||
{"kind", withKind(base, dnd.ItemOccurrenceKindAcquired), withKind(base, dnd.ItemOccurrenceKindLost)},
|
||||
{"from presence", withFrom(base, ""), withFrom(base, "Aria")},
|
||||
{"from normalized value", withFrom(base, "Aria"), withFrom(base, "Borin")},
|
||||
{"from exact value", withFrom(base, "Aria"), withFrom(base, "aria")},
|
||||
@@ -84,16 +84,16 @@ func TestLessUsesEveryCanonicalTieBreaker(t *testing.T) {
|
||||
|
||||
func TestLessAndExactEqualityCanonicalizeEvidence(t *testing.T) {
|
||||
order := testOrder()
|
||||
first := dnd.ItemEvent{
|
||||
Name: " Silver Coin ", Kind: dnd.ItemEventKindAcquired, To: " party ",
|
||||
first := dnd.ItemOccurrence{ItemID: "item",
|
||||
Name: " Silver Coin ", Kind: dnd.ItemOccurrenceKindAcquired, To: " party ",
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
},
|
||||
}
|
||||
second := dnd.ItemEvent{
|
||||
Name: "Silver Coin", Kind: dnd.ItemEventKindAcquired, To: "party",
|
||||
second := dnd.ItemOccurrence{ItemID: "item",
|
||||
Name: "Silver Coin", Kind: dnd.ItemOccurrenceKindAcquired, To: "party",
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||
@@ -136,10 +136,9 @@ func TestSourceReferenceHelpers(t *testing.T) {
|
||||
|
||||
func TestLessSortsMalformedReferencesDeterministically(t *testing.T) {
|
||||
order := testOrder()
|
||||
events := []dnd.ItemEvent{
|
||||
{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}},
|
||||
{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
events := []dnd.ItemOccurrence{{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 999, EndUnitID: 999}}},
|
||||
{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "other", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{ItemID: "item", Name: "A", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
}
|
||||
sort.SliceStable(events, func(left, right int) bool { return Less(order, events[left], events[right]) })
|
||||
if events[0].SourceRefs[0].StartUnitID != 10 || events[1].SourceRefs[0].SourceID != "other" || events[2].SourceRefs[0].StartUnitID != 999 {
|
||||
@@ -151,19 +150,28 @@ func testOrder() shared.SourceRefOrder {
|
||||
return shared.NewSourceRefOrder(&source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}})
|
||||
}
|
||||
|
||||
func withName(event dnd.ItemEvent, value string) dnd.ItemEvent { event.Name = value; return event }
|
||||
func withKind(event dnd.ItemEvent, value dnd.ItemEventKind) dnd.ItemEvent {
|
||||
func withName(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
|
||||
event.Name = value
|
||||
return event
|
||||
}
|
||||
func withKind(event dnd.ItemOccurrence, value dnd.ItemOccurrenceKind) dnd.ItemOccurrence {
|
||||
event.Kind = value
|
||||
return event
|
||||
}
|
||||
func withFrom(event dnd.ItemEvent, value string) dnd.ItemEvent { event.From = value; return event }
|
||||
func withTo(event dnd.ItemEvent, value string) dnd.ItemEvent { event.To = value; return event }
|
||||
func withoutTo(event dnd.ItemEvent) dnd.ItemEvent { event.To = ""; return event }
|
||||
func withQuantity(event dnd.ItemEvent, value *int) dnd.ItemEvent {
|
||||
func withFrom(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
|
||||
event.From = value
|
||||
return event
|
||||
}
|
||||
func withTo(event dnd.ItemOccurrence, value string) dnd.ItemOccurrence {
|
||||
event.To = value
|
||||
return event
|
||||
}
|
||||
func withoutTo(event dnd.ItemOccurrence) dnd.ItemOccurrence { event.To = ""; return event }
|
||||
func withQuantity(event dnd.ItemOccurrence, value *int) dnd.ItemOccurrence {
|
||||
event.Quantity = value
|
||||
return event
|
||||
}
|
||||
func withRefs(event dnd.ItemEvent, value []source.SourceRef) dnd.ItemEvent {
|
||||
func withRefs(event dnd.ItemOccurrence, value []source.SourceRef) dnd.ItemOccurrence {
|
||||
event.SourceRefs = value
|
||||
return event
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemeventmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/itemevents"
|
||||
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
@@ -20,85 +21,126 @@ const (
|
||||
normalizationPolicy = "dnd.item_events.normalize.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeDisplayNormalized = "item_event_display_normalized"
|
||||
ReasonCodeNameCanonicalized = "item_occurrence_name_canonicalized"
|
||||
ReasonCodeUnknownItemID = "item_occurrence_unknown_item_id"
|
||||
ReasonCodeSourceRefsNormalized = "source_references_normalized"
|
||||
ReasonCodeEventsReordered = "item_events_reordered"
|
||||
ReasonCodeDuplicateCollapsed = "duplicate_item_event_collapsed"
|
||||
ReasonCodeWarningsOmitted = "item_event_normalization_warnings_omitted"
|
||||
ReasonCodeOccurrencesReordered = "item_occurrences_reordered"
|
||||
ReasonCodeDuplicateCollapsed = "duplicate_item_occurrence_collapsed"
|
||||
ReasonCodeWarningsOmitted = "item_occurrence_normalization_warnings_omitted"
|
||||
)
|
||||
|
||||
const (
|
||||
ItemRegistryReferenceSlot = itemregistry.ReferenceSlot
|
||||
ItemRegistryMaxBytes = itemregistry.MaxBytes
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{"merged"}
|
||||
var providedCapabilities = []string{"normalized"}
|
||||
|
||||
var _ contracts.Normalizer[dnd.ItemEventList] = (*Normalizer)(nil)
|
||||
var _ contracts.Normalizer[dnd.ItemOccurrenceList] = (*Normalizer)(nil)
|
||||
var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||
|
||||
// Options deliberately has no fields: item-event normalization has no
|
||||
// generated-reference dependency or configurable semantic behavior.
|
||||
type Options struct{}
|
||||
|
||||
type Normalizer struct{}
|
||||
type Normalizer struct{ itemResolver *itemregistry.Resolver }
|
||||
|
||||
func New(Options) *Normalizer { return &Normalizer{} }
|
||||
func New(_ Options, references ...contracts.ReferenceSet) (*Normalizer, error) {
|
||||
if len(references) > 1 {
|
||||
return nil, normalizerErrorf("at most one reference set may be supplied")
|
||||
}
|
||||
var referenceSet contracts.ReferenceSet
|
||||
if len(references) == 1 {
|
||||
referenceSet = references[0]
|
||||
}
|
||||
resolver, err := itemregistry.NewResolver(referenceSet)
|
||||
if err != nil {
|
||||
return nil, normalizerErrorf("prepare item registry: %w", err)
|
||||
}
|
||||
return &Normalizer{itemResolver: resolver}, nil
|
||||
}
|
||||
|
||||
func (n *Normalizer) Key() string { return Key }
|
||||
|
||||
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return referenceSlots() }
|
||||
|
||||
func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
if n == nil {
|
||||
if n == nil || n.itemResolver == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"normalization_policy": normalizationPolicy}
|
||||
metadata := map[string]any{"normalization_policy": normalizationPolicy}
|
||||
seeded := n.itemResolver.Seeded()
|
||||
if seeded.Bound() {
|
||||
metadata["item_registry_digest"] = seeded.Digest()
|
||||
metadata["item_count"] = seeded.Count()
|
||||
}
|
||||
return metadata
|
||||
}
|
||||
|
||||
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
if n == nil {
|
||||
if n == nil || n.itemResolver == nil {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{{Name: "normalization_policy", Value: normalizationPolicy}}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
{Name: "item_registry", Value: n.itemResolver.Seeded().ProjectionDigest()},
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemEventList]) (contracts.TypedNormalizeResult[dnd.ItemEventList], error) {
|
||||
if n == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("normalizer must not be nil")
|
||||
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]) (contracts.TypedNormalizeResult[dnd.ItemOccurrenceList], error) {
|
||||
if n == nil || n.itemResolver == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("normalizer must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context must not be nil")
|
||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||
}
|
||||
|
||||
registry, err := n.itemResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("resolve item registry: %w", err)
|
||||
}
|
||||
if !registry.Bound() {
|
||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("item registry reference is required")
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index))
|
||||
return contracts.TypedNormalizeResult[dnd.ItemEventList]{Value: value, Warnings: warnings}, nil
|
||||
value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry)
|
||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{Value: value, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
type normalizedRecord struct {
|
||||
event dnd.ItemEvent
|
||||
event dnd.ItemOccurrence
|
||||
inputIndex int
|
||||
}
|
||||
|
||||
func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order shared.SourceRefOrder) (dnd.ItemEventList, []contracts.Warning) {
|
||||
if input.Events == nil {
|
||||
return dnd.ItemEventList{}, nil
|
||||
func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, []contracts.Warning) {
|
||||
if input.Occurrences == nil {
|
||||
return dnd.ItemOccurrenceList{}, nil
|
||||
}
|
||||
|
||||
records := make([]normalizedRecord, len(input.Events))
|
||||
records := make([]normalizedRecord, len(input.Occurrences))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputEvent := range input.Events {
|
||||
event, changedFields, refsChanged := normalizeEvent(inputEvent, order)
|
||||
for index, inputEvent := range input.Occurrences {
|
||||
event, changedFields, found, refsChanged := normalizeEvent(inputEvent, order, registry)
|
||||
records[index] = normalizedRecord{event: event, inputIndex: index}
|
||||
if len(changedFields) != 0 {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: eventScope(index),
|
||||
ReasonCode: ReasonCodeDisplayNormalized,
|
||||
ReasonCode: ReasonCodeNameCanonicalized,
|
||||
Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index,
|
||||
diagnostics.Aggregate("fields", changedFields)),
|
||||
})
|
||||
}
|
||||
if found && inputEvent.Name != event.Name {
|
||||
warnings = append(warnings, contracts.Warning{Scope: eventScope(index), ReasonCode: ReasonCodeNameCanonicalized,
|
||||
Message: fmt.Sprintf("input index %d: item name canonicalized from %s to %s", index, diagnostics.Quote(inputEvent.Name), diagnostics.Quote(event.Name))})
|
||||
}
|
||||
if !found {
|
||||
warnings = append(warnings, contracts.Warning{Scope: eventScope(index), ReasonCode: ReasonCodeUnknownItemID,
|
||||
Message: fmt.Sprintf("input index %d: item ID %s is not in the supplied registry", index, diagnostics.Quote(inputEvent.ItemID))})
|
||||
}
|
||||
if refsChanged {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: eventScope(index),
|
||||
@@ -118,24 +160,27 @@ func normalizeList(input dnd.ItemEventList, index source.DocumentIndex, order sh
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: eventScope(record.inputIndex),
|
||||
ReasonCode: ReasonCodeEventsReordered,
|
||||
ReasonCode: ReasonCodeOccurrencesReordered,
|
||||
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
||||
})
|
||||
}
|
||||
|
||||
output, duplicateWarnings := collapseDuplicates(records, index, order)
|
||||
warnings = append(warnings, duplicateWarnings...)
|
||||
return dnd.ItemEventList{Events: output}, diagnostics.LimitWarnings(warnings, "item_events", ReasonCodeWarningsOmitted)
|
||||
return dnd.ItemOccurrenceList{Occurrences: output}, diagnostics.LimitWarnings(warnings, "item_occurrences", ReasonCodeWarningsOmitted)
|
||||
}
|
||||
|
||||
func normalizeEvent(input dnd.ItemEvent, order shared.SourceRefOrder) (dnd.ItemEvent, []string, bool) {
|
||||
func normalizeEvent(input dnd.ItemOccurrence, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrence, []string, bool, bool) {
|
||||
output := cloneEvent(input)
|
||||
changedFields := make([]string, 0, 3)
|
||||
canonical, found := registry.LookupID(input.ItemID)
|
||||
if found {
|
||||
output.Name = canonical.Name
|
||||
}
|
||||
changedFields := make([]string, 0, 2)
|
||||
for _, field := range []struct {
|
||||
name string
|
||||
value *string
|
||||
}{
|
||||
{name: "name", value: &output.Name},
|
||||
{name: "from", value: &output.From},
|
||||
{name: "to", value: &output.To},
|
||||
} {
|
||||
@@ -146,10 +191,10 @@ func normalizeEvent(input dnd.ItemEvent, order shared.SourceRefOrder) (dnd.ItemE
|
||||
}
|
||||
}
|
||||
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||
return output, changedFields, !itemeventmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||
return output, changedFields, found, !itemeventmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||
}
|
||||
|
||||
func cloneEvent(input dnd.ItemEvent) dnd.ItemEvent {
|
||||
func cloneEvent(input dnd.ItemOccurrence) dnd.ItemOccurrence {
|
||||
output := input
|
||||
if input.Quantity != nil {
|
||||
quantity := *input.Quantity
|
||||
@@ -166,9 +211,9 @@ type duplicateGroup struct {
|
||||
removed []int
|
||||
}
|
||||
|
||||
func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex, order shared.SourceRefOrder) ([]dnd.ItemEvent, []contracts.Warning) {
|
||||
func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex, order shared.SourceRefOrder) ([]dnd.ItemOccurrence, []contracts.Warning) {
|
||||
if len(records) == 0 {
|
||||
return make([]dnd.ItemEvent, 0), nil
|
||||
return make([]dnd.ItemOccurrence, 0), nil
|
||||
}
|
||||
|
||||
keep := make([]bool, len(records))
|
||||
@@ -190,7 +235,7 @@ func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex,
|
||||
groups[groupIndex].removed = append(groups[groupIndex].removed, record.inputIndex)
|
||||
}
|
||||
|
||||
output := make([]dnd.ItemEvent, 0, len(records))
|
||||
output := make([]dnd.ItemOccurrence, 0, len(records))
|
||||
for recordIndex, record := range records {
|
||||
if keep[recordIndex] {
|
||||
output = append(output, cloneEvent(record.event))
|
||||
@@ -214,11 +259,11 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning {
|
||||
Scope: eventScope(retainedIndex),
|
||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||
Message: diagnostics.Aggregate(
|
||||
fmt.Sprintf("duplicate item event collapsed; retained input index %d", retainedIndex), issues),
|
||||
fmt.Sprintf("duplicate item occurrence collapsed; retained input index %d", retainedIndex), issues),
|
||||
}
|
||||
}
|
||||
|
||||
func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) }
|
||||
func eventScope(index int) string { return fmt.Sprintf("occurrences[%d]", index) }
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
@@ -227,20 +272,32 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
ExecutionClass: contracts.ExecutionClassDeterministic,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ArtifactKind: dnd.ItemEventListKind,
|
||||
ArtifactKind: dnd.ItemOccurrenceListKind,
|
||||
ReferenceSlots: referenceSlots(),
|
||||
}
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.NormalizerRegistry) error {
|
||||
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterNormalizerBuilder(registry, ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Normalizer[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options), nil
|
||||
return New(options, request.References)
|
||||
})
|
||||
}
|
||||
|
||||
func referenceSlots() []contracts.ReferenceSlot {
|
||||
return []contracts.ReferenceSlot{{
|
||||
Name: ItemRegistryReferenceSlot,
|
||||
Description: "Required normalized item registry used only for item identity grounding.",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{"application/json"},
|
||||
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.ItemRegistryKind},
|
||||
MaxBytes: ItemRegistryMaxBytes,
|
||||
}}
|
||||
}
|
||||
|
||||
func DecodeOptions(options map[string]any) (Options, error) {
|
||||
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
||||
return Options{}, normalizerErrorf("%w", err)
|
||||
@@ -251,5 +308,5 @@ func DecodeOptions(options map[string]any) (Options, error) {
|
||||
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
||||
|
||||
func normalizerErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd item events normalizer: "+format, args...)
|
||||
return fmt.Errorf("dnd item occurrences normalizer: "+format, args...)
|
||||
}
|
||||
|
||||
@@ -2,186 +2,65 @@ package itemevents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
|
||||
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
|
||||
)
|
||||
|
||||
func TestNormalizeTrimsCanonicalizesAndClones(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||
quantity := 4
|
||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{{
|
||||
Name: " Gold Pieces ", Kind: dnd.ItemEventKindTransferred, Quantity: &quantity, From: " Borin ", To: " Aria ",
|
||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
||||
}}}
|
||||
result := normalize(t, doc, input)
|
||||
got := result.Value.Events[0]
|
||||
if got.Name != "Gold Pieces" || got.From != "Borin" || got.To != "Aria" || got.Quantity == input.Events[0].Quantity ||
|
||||
!reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
|
||||
t.Fatalf("normalized event = %#v", got)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeDisplayNormalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
second := normalize(t, doc, result.Value)
|
||||
if !reflect.DeepEqual(second.Value, result.Value) || len(second.Warnings) != 0 {
|
||||
t.Fatalf("second normalization = %#v; want idempotent output without warnings", second)
|
||||
}
|
||||
*got.Quantity = 99
|
||||
got.SourceRefs[0].StartUnitID = 999
|
||||
if quantity != 4 || input.Events[0].SourceRefs[1].StartUnitID != 30 {
|
||||
t.Fatalf("Normalize() aliased input: %#v", input)
|
||||
func registryReferences(t *testing.T) contracts.ReferenceSet {
|
||||
t.Helper()
|
||||
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: itemidentity.DeriveID("Torch"), Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||
}
|
||||
|
||||
func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}, {ID: 20}}}
|
||||
ref := func(unit int) source.SourceRef {
|
||||
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
|
||||
func TestNormalizeCanonicalizesRegistryNameAndRetainsUnknownValues(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}}}
|
||||
refs := registryReferences(t)
|
||||
normalizer, err := New(Options{}, refs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quantity := 2
|
||||
duplicate := dnd.ItemEvent{Name: " Potion ", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " Aria ", SourceRefs: []source.SourceRef{ref(30)}}
|
||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: "Later", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(20)}},
|
||||
duplicate,
|
||||
{Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Aria", SourceRefs: []source.SourceRef{ref(30)}},
|
||||
{Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Borin", SourceRefs: []source.SourceRef{ref(30)}},
|
||||
{Name: "Potion", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: "Aria", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}},
|
||||
id := itemidentity.DeriveID("Torch")
|
||||
input := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{
|
||||
{ItemID: id, Name: "torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
||||
{ItemID: "unknown", Name: "Unknown", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
|
||||
}}
|
||||
result := normalize(t, doc, input)
|
||||
got := result.Value.Events
|
||||
if len(got) != 4 || got[0].Name != "Potion" || got[0].To != "Aria" || got[1].To != "Borin" || got[2].Name != "Later" || got[3].SourceRefs[0].StartUnitID != 999 {
|
||||
t.Fatalf("normalized events = %#v", got)
|
||||
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{Source: doc, References: refs, MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: input}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeEventsReordered) || !hasWarning(result.Warnings, ReasonCodeDuplicateCollapsed) {
|
||||
if result.Value.Occurrences[1].Name != "Torch" || result.Value.Occurrences[0].Name != "Unknown" {
|
||||
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeUnknownItemID) {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeUsesEveryCanonicalTieBreaker(t *testing.T) {
|
||||
doc := testDocument()
|
||||
ref := func(unit int) source.SourceRef {
|
||||
return source.SourceRef{SourceID: doc.ID, StartUnitID: unit, EndUnitID: unit}
|
||||
func TestNormalizeRequiresRegistryAndRegistersSlot(t *testing.T) {
|
||||
normalizer, err := New(Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quantity := 1
|
||||
tests := []struct {
|
||||
name string
|
||||
earlier dnd.ItemEvent
|
||||
later dnd.ItemEvent
|
||||
}{
|
||||
{"evidence position", dnd.ItemEvent{Name: "Z", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(20)}}},
|
||||
{"normalized name", dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "B", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"exact name", dnd.ItemEvent{Name: "A", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "a", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"kind", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindAcquired, To: "Aria", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"from presence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"from comparison", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "B", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"exact from", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "a", To: "Z", SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"to presence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, To: "Aria", SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"to comparison", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "A", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "B", SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"exact to", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "A", SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindTransferred, From: "A", To: "a", SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"quantity", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: []source.SourceRef{ref(10)}}},
|
||||
{"reference sequence", dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10)}}, dnd.ItemEvent{Name: "Item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{ref(10), ref(20)}}},
|
||||
_, err = normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemOccurrenceList]{MergeOutput: contracts.MergeArtifact[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}}})
|
||||
if err == nil {
|
||||
t.Fatal("Normalize() error = nil")
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result := normalize(t, doc, dnd.ItemEventList{Events: []dnd.ItemEvent{test.later, test.earlier}})
|
||||
if len(result.Value.Events) != 2 || !reflect.DeepEqual(result.Value.Events[0], test.earlier) || !reflect.DeepEqual(result.Value.Events[1], test.later) {
|
||||
t.Fatalf("normalized events = %#v; want %#v then %#v", result.Value.Events, test.earlier, test.later)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreservesEmptyAndMalformedCandidates(t *testing.T) {
|
||||
for _, input := range []dnd.ItemEventList{{}, {Events: []dnd.ItemEvent{}}} {
|
||||
result := normalize(t, nil, input)
|
||||
if (result.Value.Events == nil) != (input.Events == nil) {
|
||||
t.Fatalf("empty representation changed: %#v", result.Value)
|
||||
}
|
||||
}
|
||||
malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
|
||||
{Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}},
|
||||
}}
|
||||
result := normalize(t, testDocument(), malformed)
|
||||
if len(result.Value.Events) != 2 || result.Value.Events[0].Name != "Coin" || result.Value.Events[1].Name != "Coin" {
|
||||
t.Fatalf("malformed candidates were not safely retained: %#v", result.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDoesNotInferOrConvertInventoryMeaning(t *testing.T) {
|
||||
doc := testDocument()
|
||||
quantity := 25
|
||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: " gold pieces ", Kind: dnd.ItemEventKindDiscovered, Quantity: &quantity, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
||||
{Name: " gp ", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
||||
{Name: " potion of healing ", Kind: dnd.ItemEventKindTransferred, Quantity: &quantity, From: " Aria ", To: " Borin ", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
||||
}}
|
||||
result := normalize(t, doc, input)
|
||||
got := make(map[string]dnd.ItemEvent, len(result.Value.Events))
|
||||
for _, event := range result.Value.Events {
|
||||
got[event.Name] = event
|
||||
}
|
||||
if event := got["gold pieces"]; event.Kind != dnd.ItemEventKindDiscovered || event.Quantity == nil || *event.Quantity != 25 {
|
||||
t.Fatalf("discovery event was inferred or converted: %#v", event)
|
||||
}
|
||||
if event := got["gp"]; event.Kind != dnd.ItemEventKindAcquired || event.Quantity == nil || *event.Quantity != 25 || event.To != "party" {
|
||||
t.Fatalf("acquisition event was inferred or denomination-converted: %#v", event)
|
||||
}
|
||||
if event := got["potion of healing"]; event.Kind != dnd.ItemEventKindTransferred || event.From != "Aria" || event.To != "Borin" || event.Quantity == nil || *event.Quantity != 25 {
|
||||
t.Fatalf("transfer event was inferred, aliased, singularized, or ledger-adjusted: %#v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizerContractAndBoundedWarnings(t *testing.T) {
|
||||
normalizer := New(Options{})
|
||||
if spec := ModuleSpec(); spec.Key != Key || spec.Stage != pipeline.StageNormalize || spec.ArtifactKind != dnd.ItemEventListKind || len(spec.ReferenceSlots) != 0 ||
|
||||
!reflect.DeepEqual(spec.Requires, []string{"merged"}) || !reflect.DeepEqual(spec.Provides, []string{"normalized"}) {
|
||||
t.Fatalf("ModuleSpec() = %#v", spec)
|
||||
}
|
||||
if metadata := normalizer.ManifestMetadata(); !reflect.DeepEqual(metadata, map[string]any{"normalization_policy": normalizationPolicy}) {
|
||||
t.Fatalf("ManifestMetadata() = %#v", metadata)
|
||||
}
|
||||
if fingerprints := normalizer.CheckpointFingerprints(); !reflect.DeepEqual(fingerprints, []pipeline.CheckpointFingerprint{{Name: "normalization_policy", Value: normalizationPolicy}}) {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v", fingerprints)
|
||||
if len(ModuleSpec().ReferenceSlots) != 1 || !ModuleSpec().ReferenceSlots[0].Required {
|
||||
t.Fatalf("ModuleSpec() = %#v", ModuleSpec())
|
||||
}
|
||||
registry := pipeline.NewNormalizerRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||
t.Fatal("DecodeOptions() accepted unknown option")
|
||||
}
|
||||
|
||||
count := diagnostics.MaxWarnings + 5
|
||||
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
||||
input := dnd.ItemEventList{Events: make([]dnd.ItemEvent, count)}
|
||||
for index := range input.Events {
|
||||
doc.Units[index].ID = index + 1
|
||||
input.Events[index] = dnd.ItemEvent{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: count - index, EndUnitID: count - index}}}
|
||||
}
|
||||
result := normalize(t, doc, input)
|
||||
if len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != ReasonCodeWarningsOmitted {
|
||||
t.Fatalf("warnings = %#v", result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func normalize(t *testing.T, doc *source.SourceDocument, input dnd.ItemEventList) contracts.TypedNormalizeResult[dnd.ItemEventList] {
|
||||
t.Helper()
|
||||
result, err := New(Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func testDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
|
||||
}
|
||||
|
||||
func hasWarning(warnings []contracts.Warning, reason string) bool {
|
||||
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
|
||||
enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness"
|
||||
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
|
||||
itemeventregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/registry"
|
||||
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
||||
itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs"
|
||||
itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness"
|
||||
@@ -181,26 +182,28 @@ func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "item events validator chain", register: func() error {
|
||||
{name: "item occurrences validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: itemeventextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(itemeventshape.Key),
|
||||
pipeline.Binding(itemeventregistry.Key),
|
||||
pipeline.Binding(itemeventrefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(itemeventrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "item events normalize validator chain", register: func() error {
|
||||
{name: "item occurrences normalize validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: itemeventnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(itemeventshape.Key),
|
||||
pipeline.Binding(itemeventregistry.Key),
|
||||
pipeline.Binding(itemeventinvariants.Key),
|
||||
pipeline.Binding(itemeventrefs.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
|
||||
@@ -16,8 +16,8 @@ func registerEvidence(registry *pipeline.ArtifactEvidenceRegistry) error {
|
||||
{name: "enemy events evidence", register: func() error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.EnemyEventListKind, enemyEventEvidence)
|
||||
}},
|
||||
{name: "item events evidence", register: func() error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemEventListKind, itemEventEvidence)
|
||||
{name: "item occurrences evidence", register: func() error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemOccurrenceListKind, itemOccurrenceEvidence)
|
||||
}},
|
||||
{name: "item registry evidence", register: func() error {
|
||||
return pipeline.RegisterArtifactEvidence(registry, dnd.ItemRegistryKind, itemRegistryEvidence)
|
||||
@@ -69,9 +69,9 @@ func enemyEventEvidence(value dnd.EnemyEventList) []source.SourceRef {
|
||||
return append([]source.SourceRef(nil), refs...)
|
||||
}
|
||||
|
||||
func itemEventEvidence(value dnd.ItemEventList) []source.SourceRef {
|
||||
func itemOccurrenceEvidence(value dnd.ItemOccurrenceList) []source.SourceRef {
|
||||
var refs []source.SourceRef
|
||||
for _, record := range value.Events {
|
||||
for _, record := range value.Occurrences {
|
||||
refs = append(refs, record.SourceRefs...)
|
||||
}
|
||||
return append([]source.SourceRef(nil), refs...)
|
||||
|
||||
@@ -91,22 +91,22 @@ func appendEnemyEventLists(values []dnd.EnemyEventList) (dnd.EnemyEventList, err
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendItemEventLists(values []dnd.ItemEventList) (dnd.ItemEventList, error) {
|
||||
func appendItemOccurrenceLists(values []dnd.ItemOccurrenceList) (dnd.ItemOccurrenceList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.Events != nil {
|
||||
if value.Occurrences != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.Events)
|
||||
count += len(value.Occurrences)
|
||||
}
|
||||
if !present {
|
||||
return dnd.ItemEventList{}, nil
|
||||
return dnd.ItemOccurrenceList{}, nil
|
||||
}
|
||||
combined := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 0, count)}
|
||||
combined := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 0, count)}
|
||||
for _, value := range values {
|
||||
for _, event := range value.Events {
|
||||
combined.Events = append(combined.Events, cloneItemEvent(event))
|
||||
for _, occurrence := range value.Occurrences {
|
||||
combined.Occurrences = append(combined.Occurrences, cloneItemOccurrence(occurrence))
|
||||
}
|
||||
}
|
||||
return combined, nil
|
||||
@@ -227,7 +227,7 @@ func cloneEnemyEvent(value dnd.EnemyEvent) dnd.EnemyEvent {
|
||||
return clone
|
||||
}
|
||||
|
||||
func cloneItemEvent(value dnd.ItemEvent) dnd.ItemEvent {
|
||||
func cloneItemOccurrence(value dnd.ItemOccurrence) dnd.ItemOccurrence {
|
||||
clone := value
|
||||
if value.Quantity != nil {
|
||||
quantity := *value.Quantity
|
||||
|
||||
@@ -47,7 +47,7 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "npc registry codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
||||
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
||||
{name: "enemy events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, enemyeventcodec.New()) }},
|
||||
{name: "item events codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }},
|
||||
{name: "item occurrences codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemeventcodec.New()) }},
|
||||
{name: "item registry codec", register: func() error {
|
||||
return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, itemregistrycodec.New())
|
||||
}},
|
||||
@@ -64,7 +64,7 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "npc registry extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
||||
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
||||
{name: "enemy events extractor", register: func() error { return enemyeventextract.Register(registries.Extractors) }},
|
||||
{name: "item events extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }},
|
||||
{name: "item occurrences extractor", register: func() error { return itemeventextract.Register(registries.Extractors) }},
|
||||
{name: "item registry extractor", register: func() error { return itemregistryextract.Register(registries.Extractors) }},
|
||||
{name: "npc occurrences extractor", register: func() error { return occurrenceextract.Register(registries.Extractors) }},
|
||||
{name: "scene descriptions extractor", register: func() error { return scenedescriptionextract.Register(registries.Extractors) }},
|
||||
@@ -82,8 +82,8 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "enemy-event-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.EnemyEventListKind, appendEnemyEventLists)
|
||||
}},
|
||||
{name: "item-event-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemEventListKind, appendItemEventLists)
|
||||
{name: "item-occurrence-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemOccurrenceListKind, appendItemOccurrenceLists)
|
||||
}},
|
||||
{name: "item-registry appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.ItemRegistryKind, appendItemRegistries)
|
||||
@@ -104,7 +104,7 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "npc registry normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
||||
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
||||
{name: "enemy events normalizer", register: func() error { return enemyeventnormalize.Register(registries.Normalizers) }},
|
||||
{name: "item events normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }},
|
||||
{name: "item occurrences normalizer", register: func() error { return itemeventnormalize.Register(registries.Normalizers) }},
|
||||
{name: "item registry normalizer", register: func() error { return itemregistrynormalize.Register(registries.Normalizers) }},
|
||||
{name: "npc occurrences normalizer", register: func() error { return occurrencenormalize.Register(registries.Normalizers) }},
|
||||
{name: "scene descriptions normalizer", register: func() error { return scenedescriptionnormalize.Register(registries.Normalizers) }},
|
||||
@@ -122,8 +122,8 @@ func registerModules(registries pipeline.Registries) error {
|
||||
{name: "enemy-event-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.EnemyEventList](registries.Normalizers, dnd.EnemyEventListKind)
|
||||
}},
|
||||
{name: "item-event-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.ItemEventList](registries.Normalizers, dnd.ItemEventListKind)
|
||||
{name: "item-occurrence-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.ItemOccurrenceList](registries.Normalizers, dnd.ItemOccurrenceListKind)
|
||||
}},
|
||||
{name: "item-registry noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.ItemRegistry](registries.Normalizers, dnd.ItemRegistryKind)
|
||||
@@ -152,7 +152,7 @@ func registerPromptAssets(assets *llm.AssetRegistry) error {
|
||||
{name: "npc normalization prompt assets", register: func() error { return npcnormalize.RegisterPromptAssets(assets) }},
|
||||
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
|
||||
{name: "enemy events prompt assets", register: func() error { return enemyeventextract.RegisterPromptAssets(assets) }},
|
||||
{name: "item events prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }},
|
||||
{name: "item occurrences prompt assets", register: func() error { return itemeventextract.RegisterPromptAssets(assets) }},
|
||||
{name: "item registry prompt assets", register: func() error { return itemregistryextract.RegisterPromptAssets(assets) }},
|
||||
{name: "item registry normalization prompt assets", register: func() error { return itemregistrynormalize.RegisterPromptAssets(assets) }},
|
||||
{name: "npc occurrences prompt assets", register: func() error { return occurrenceextract.RegisterPromptAssets(assets) }},
|
||||
|
||||
@@ -51,7 +51,9 @@ func TestExtractionPromptComposition(t *testing.T) {
|
||||
}{
|
||||
{name: "npcs", promptID: npcextract.PromptID, promptVersion: npcextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
||||
{name: "locations", promptID: locationextract.PromptID, promptVersion: locationextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
||||
{name: "item events", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
||||
{name: "item occurrences", promptID: itemeventextract.PromptID, promptVersion: itemeventextract.SchemaVersion, inputs: withPromptInputs(commonInputs, map[string]promptkit.ArtifactRef{
|
||||
"item_registry": promptkit.Inline(`{"items":[{"id":"item-registry-sentinel","name":"Torch"}]}`),
|
||||
}), suffixGroups: [][]string{{evidenceSentinel}, {"item-registry-sentinel"}}},
|
||||
{name: "item registry", promptID: itemregistryextract.PromptID, promptVersion: itemregistryextract.SchemaVersion, inputs: commonInputs, suffixGroups: [][]string{{evidenceSentinel}}},
|
||||
{name: "scene descriptions", promptID: scenedescriptionextract.PromptID, promptVersion: scenedescriptionextract.SchemaVersion, inputs: commonInputs},
|
||||
{
|
||||
|
||||
@@ -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{}}}}})
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
enemyeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_refs"
|
||||
enemyeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/enemyevents/source_relatedness"
|
||||
itemeventinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/invariants"
|
||||
itemeventregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/registry"
|
||||
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
||||
itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs"
|
||||
itemeventrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_relatedness"
|
||||
@@ -69,10 +70,11 @@ func registerValidators(registries pipeline.Registries) error {
|
||||
{name: "enemy event source references validator", register: func() error { return enemyeventrefs.Register(registries.Validators) }},
|
||||
{name: "enemy event source relatedness validator", register: func() error { return enemyeventrelatedness.Register(registries.Validators) }},
|
||||
{name: "enemy event normalized invariants validator", register: func() error { return enemyeventinvariants.Register(registries.Validators) }},
|
||||
{name: "item event shape validator", register: func() error { return itemeventshape.Register(registries.Validators) }},
|
||||
{name: "item event source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }},
|
||||
{name: "item event source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }},
|
||||
{name: "item event normalized invariants validator", register: func() error { return itemeventinvariants.Register(registries.Validators) }},
|
||||
{name: "item occurrence shape validator", register: func() error { return itemeventshape.Register(registries.Validators) }},
|
||||
{name: "item occurrence registry validator", register: func() error { return itemeventregistry.Register(registries.Validators) }},
|
||||
{name: "item occurrence source references validator", register: func() error { return itemeventrefs.Register(registries.Validators) }},
|
||||
{name: "item occurrence source relatedness validator", register: func() error { return itemeventrelatedness.Register(registries.Validators) }},
|
||||
{name: "item occurrence normalized invariants validator", register: func() error { return itemeventinvariants.Register(registries.Validators) }},
|
||||
{name: "item shape validator", register: func() error { return itemshape.Register(registries.Validators) }},
|
||||
{name: "item identity validator", register: func() error { return itemidentity.Register(registries.Validators) }},
|
||||
{name: "item source references validator", register: func() error { return itemrefs.Register(registries.Validators) }},
|
||||
@@ -119,11 +121,11 @@ func registerValidators(registries pipeline.Registries) error {
|
||||
{name: "enemy-event-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.EnemyEventList](registries.Validators, dnd.EnemyEventListKind)
|
||||
}},
|
||||
{name: "item-event-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
||||
{name: "item-occurrence-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind)
|
||||
}},
|
||||
{name: "item-event-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.ItemEventList](registries.Validators, dnd.ItemEventListKind)
|
||||
{name: "item-occurrence-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.ItemOccurrenceList](registries.Validators, dnd.ItemOccurrenceListKind)
|
||||
}},
|
||||
{name: "item-registry always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.ItemRegistry](registries.Validators, dnd.ItemRegistryKind)
|
||||
|
||||
@@ -16,7 +16,7 @@ const NPCOccurrenceListKind contracts.ArtifactKind = "dnd/npc-occurrence-list"
|
||||
|
||||
const SceneDescriptionListKind contracts.ArtifactKind = "dnd/scene-description-list"
|
||||
|
||||
const ItemEventListKind contracts.ArtifactKind = "dnd/item-event-list"
|
||||
const ItemOccurrenceListKind contracts.ArtifactKind = "dnd/item-occurrence-list"
|
||||
|
||||
const ItemRegistryKind contracts.ArtifactKind = "dnd/item-registry"
|
||||
|
||||
@@ -109,23 +109,24 @@ type SceneDescription struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
type ItemEventKind string
|
||||
type ItemOccurrenceKind string
|
||||
|
||||
const (
|
||||
ItemEventKindDiscovered ItemEventKind = "discovered"
|
||||
ItemEventKindAcquired ItemEventKind = "acquired"
|
||||
ItemEventKindLost ItemEventKind = "lost"
|
||||
ItemEventKindConsumed ItemEventKind = "consumed"
|
||||
ItemEventKindTransferred ItemEventKind = "transferred"
|
||||
ItemOccurrenceKindDiscovered ItemOccurrenceKind = "discovered"
|
||||
ItemOccurrenceKindAcquired ItemOccurrenceKind = "acquired"
|
||||
ItemOccurrenceKindLost ItemOccurrenceKind = "lost"
|
||||
ItemOccurrenceKindConsumed ItemOccurrenceKind = "consumed"
|
||||
ItemOccurrenceKindTransferred ItemOccurrenceKind = "transferred"
|
||||
)
|
||||
|
||||
type ItemEventList struct {
|
||||
Events []ItemEvent `json:"events"`
|
||||
type ItemOccurrenceList struct {
|
||||
Occurrences []ItemOccurrence `json:"occurrences"`
|
||||
}
|
||||
|
||||
type ItemEvent struct {
|
||||
type ItemOccurrence struct {
|
||||
ItemID string `json:"item_id"`
|
||||
Name string `json:"name"`
|
||||
Kind ItemEventKind `json:"kind"`
|
||||
Kind ItemOccurrenceKind `json:"kind"`
|
||||
Quantity *int `json:"quantity,omitempty"`
|
||||
From string `json:"from,omitempty"`
|
||||
To string `json:"to,omitempty"`
|
||||
|
||||
@@ -24,7 +24,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -36,7 +36,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Source, req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
|
||||
// Validate checks only invariants introduced by item-event normalization.
|
||||
// Shape and source-reference failures remain owned by their earlier validators.
|
||||
func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
|
||||
func Validate(doc *source.SourceDocument, value dnd.ItemOccurrenceList) error {
|
||||
if itemeventshape.Validate(value) != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -57,14 +57,14 @@ func Validate(doc *source.SourceDocument, value dnd.ItemEventList) error {
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item event invariants", issues))
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid normalized item occurrence invariants", issues))
|
||||
}
|
||||
|
||||
func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
|
||||
func issuesFor(order shared.SourceRefOrder, value dnd.ItemOccurrenceList) []string {
|
||||
issues := make([]string, 0)
|
||||
seenIdentity := make(map[string]int)
|
||||
for eventIndex, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", eventIndex)
|
||||
for eventIndex, event := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", eventIndex)
|
||||
if event.Name != itemeventmodel.DisplayValue(event.Name) {
|
||||
issues = append(issues, prefix+".name is not display-normalized")
|
||||
}
|
||||
@@ -85,21 +85,21 @@ func issuesFor(order shared.SourceRefOrder, value dnd.ItemEventList) []string {
|
||||
}
|
||||
key := itemeventmodel.ExactIdentity(order, event)
|
||||
if previous, exists := seenIdentity[key]; exists {
|
||||
issues = append(issues, fmt.Sprintf("%s duplicates item event %d under normalized identity", prefix, previous))
|
||||
issues = append(issues, fmt.Sprintf("%s duplicates item occurrence %d under normalized identity", prefix, previous))
|
||||
} else {
|
||||
seenIdentity[key] = eventIndex
|
||||
}
|
||||
}
|
||||
for eventIndex := 1; eventIndex < len(value.Events); eventIndex++ {
|
||||
if itemeventmodel.Less(order, value.Events[eventIndex], value.Events[eventIndex-1]) {
|
||||
issues = append(issues, fmt.Sprintf("events[%d] is out of canonical order", eventIndex))
|
||||
for eventIndex := 1; eventIndex < len(value.Occurrences); eventIndex++ {
|
||||
if itemeventmodel.Less(order, value.Occurrences[eventIndex], value.Occurrences[eventIndex-1]) {
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d] is out of canonical order", eventIndex))
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemEventList) bool {
|
||||
for _, event := range value.Events {
|
||||
func sourceRefsValid(index source.DocumentIndex, value dnd.ItemOccurrenceList) bool {
|
||||
for _, event := range value.Occurrences {
|
||||
if !itemeventmodel.ValidSourceRefs(index, event.SourceRefs) {
|
||||
return false
|
||||
}
|
||||
@@ -112,7 +112,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -11,20 +11,11 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
normalizeitemevents "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||
)
|
||||
|
||||
func TestValidatorApprovesNormalizerOutput(t *testing.T) {
|
||||
func TestValidatorApprovesNormalizedOutput(t *testing.T) {
|
||||
doc := invariantDocument()
|
||||
input := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: " Coin ", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
|
||||
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
||||
}}
|
||||
normalized, err := normalizeitemevents.New(normalizeitemevents.Options{}).Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.ItemEventList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.ItemEventList]{Value: input}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: normalized.Value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: normalizedList()})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v; want approval", result, err)
|
||||
}
|
||||
@@ -33,26 +24,30 @@ func TestValidatorApprovesNormalizerOutput(t *testing.T) {
|
||||
func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*dnd.ItemEventList)
|
||||
mutate func(*dnd.ItemOccurrenceList)
|
||||
want string
|
||||
}{
|
||||
{name: "name whitespace", mutate: func(value *dnd.ItemEventList) { value.Events[0].Name = " Coin " }, want: ".name is not display-normalized"},
|
||||
{name: "from whitespace", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].Kind = dnd.ItemEventKindLost
|
||||
value.Events[0].From = " Aria "
|
||||
{name: "name whitespace", mutate: func(value *dnd.ItemOccurrenceList) { value.Occurrences[0].Name = " Coin " }, want: ".name is not display-normalized"},
|
||||
{name: "from whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindLost
|
||||
value.Occurrences[0].From = " Aria "
|
||||
}, want: ".from is not display-normalized"},
|
||||
{name: "to whitespace", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].Kind = dnd.ItemEventKindAcquired
|
||||
value.Events[0].To = " Aria "
|
||||
{name: "to whitespace", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].Kind = dnd.ItemOccurrenceKindAcquired
|
||||
value.Occurrences[0].To = " Aria "
|
||||
}, want: ".to is not display-normalized"},
|
||||
{name: "reference order", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
{name: "reference order", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}, {SourceID: "session", StartUnitID: 10, EndUnitID: 10}}
|
||||
}, want: "not in canonical order"},
|
||||
{name: "duplicate reference", mutate: func(value *dnd.ItemEventList) {
|
||||
value.Events[0].SourceRefs = append(value.Events[0].SourceRefs, value.Events[0].SourceRefs[0])
|
||||
{name: "duplicate reference", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences[0].SourceRefs = append(value.Occurrences[0].SourceRefs, value.Occurrences[0].SourceRefs[0])
|
||||
}, want: "duplicates the previous reference"},
|
||||
{name: "list order", mutate: func(value *dnd.ItemEventList) { value.Events = []dnd.ItemEvent{value.Events[1], value.Events[0]} }, want: "out of canonical order"},
|
||||
{name: "duplicate event", mutate: func(value *dnd.ItemEventList) { value.Events = append(value.Events, value.Events[0]) }, want: "duplicates item event"},
|
||||
{name: "list order", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences = []dnd.ItemOccurrence{value.Occurrences[1], value.Occurrences[0]}
|
||||
}, want: "out of canonical order"},
|
||||
{name: "duplicate event", mutate: func(value *dnd.ItemOccurrenceList) {
|
||||
value.Occurrences = append(value.Occurrences, value.Occurrences[0])
|
||||
}, want: "duplicates item occurrence"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
@@ -62,7 +57,7 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("Validate() error = %v, want %q", err, test.want)
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("validator result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -72,20 +67,20 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
||||
|
||||
func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{{
|
||||
Name: "Coin", Kind: dnd.ItemEventKindDiscovered,
|
||||
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{
|
||||
ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered,
|
||||
SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}},
|
||||
}}}
|
||||
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") {
|
||||
t.Fatalf("Validate() error = %v, want document-order rejection", err)
|
||||
}
|
||||
shapeInvalid := normalizedList()
|
||||
shapeInvalid.Events[0].Name = " "
|
||||
shapeInvalid.Occurrences[0].Name = " "
|
||||
if err := Validate(invariantDocument(), shapeInvalid); err != nil {
|
||||
t.Fatalf("shape failure must be deferred, got %v", err)
|
||||
}
|
||||
sourceInvalid := normalizedList()
|
||||
sourceInvalid.Events[0].SourceRefs[0].StartUnitID = 999
|
||||
sourceInvalid.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||
if err := Validate(invariantDocument(), sourceInvalid); err != nil {
|
||||
t.Fatalf("source-reference failure must be deferred, got %v", err)
|
||||
}
|
||||
@@ -93,12 +88,12 @@ func TestValidateUsesSourceDocumentOrderAndDefersEarlierFailures(t *testing.T) {
|
||||
|
||||
func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
|
||||
value := normalizedList()
|
||||
value.Events = make([]dnd.ItemEvent, 30)
|
||||
for index := range value.Events {
|
||||
value.Events[index] = normalizedList().Events[0]
|
||||
value.Events[index].Name = strings.Repeat("火", 300)
|
||||
value.Occurrences = make([]dnd.ItemOccurrence, 30)
|
||||
for index := range value.Occurrences {
|
||||
value.Occurrences[index] = normalizedList().Occurrences[0]
|
||||
value.Occurrences[index].Name = strings.Repeat("火", 300)
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: invariantDocument(), Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: invariantDocument(), Value: value})
|
||||
if err != nil || result.Approved || !utf8.ValidString(result.Message) || len([]byte(result.Message)) > 4096 || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
t.Fatalf("bounded result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -117,10 +112,9 @@ func TestValidatorContractAndBoundedDiagnostics(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedList() dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: "Arrow", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
{Name: "Coin", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
func normalizedList() dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Arrow", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}},
|
||||
{ItemID: "item", Name: "Coin", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 20, EndUnitID: 20}}},
|
||||
}}
|
||||
}
|
||||
|
||||
|
||||
119
internal/modules/dnd/validate/itemevents/registry/validator.go
Normal file
119
internal/modules/dnd/validate/itemevents/registry/validator.go
Normal 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 }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -34,7 +34,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: err.Error()}, nil
|
||||
}
|
||||
@@ -42,21 +42,24 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
|
||||
// Validate returns one bounded error for every owned item-event shape issue.
|
||||
func Validate(value dnd.ItemEventList) error {
|
||||
func Validate(value dnd.ItemOccurrenceList) error {
|
||||
issues := issuesFor(value)
|
||||
if len(issues) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item event shape", issues))
|
||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid item occurrence shape", issues))
|
||||
}
|
||||
|
||||
func issuesFor(value dnd.ItemEventList) []string {
|
||||
if value.Events == nil {
|
||||
return []string{"events must be present"}
|
||||
func issuesFor(value dnd.ItemOccurrenceList) []string {
|
||||
if value.Occurrences == nil {
|
||||
return []string{"occurrences must be present"}
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for index, event := range value.Events {
|
||||
prefix := fmt.Sprintf("events[%d]", index)
|
||||
for index, event := range value.Occurrences {
|
||||
prefix := fmt.Sprintf("occurrences[%d]", index)
|
||||
if strings.TrimSpace(event.ItemID) == "" {
|
||||
issues = append(issues, prefix+".item_id must not be empty: "+diagnostics.Quote(event.ItemID))
|
||||
}
|
||||
if strings.TrimSpace(event.Name) == "" {
|
||||
issues = append(issues, prefix+".name must not be empty: "+diagnostics.Quote(event.Name))
|
||||
}
|
||||
@@ -80,7 +83,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,18 +15,17 @@ import (
|
||||
|
||||
func TestValidatorAcceptsEveryEventRuleAndNormalizableWhitespace(t *testing.T) {
|
||||
quantity := 1
|
||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: " Hidden Cache ", Kind: dnd.ItemEventKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
|
||||
{Name: "Gold Pieces", Kind: dnd.ItemEventKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
|
||||
{Name: "Torch", Kind: dnd.ItemEventKindLost, From: " party ", SourceRefs: refs(3, 3)},
|
||||
{Name: "Potion", Kind: dnd.ItemEventKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
|
||||
{Name: "Moonblade", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: " Hidden Cache ", Kind: dnd.ItemOccurrenceKindDiscovered, From: " ", To: " ", SourceRefs: refs(1, 1)},
|
||||
{ItemID: "item", Name: "Gold Pieces", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &quantity, To: " party ", SourceRefs: refs(2, 2)},
|
||||
{ItemID: "item", Name: "Torch", Kind: dnd.ItemOccurrenceKindLost, From: " party ", SourceRefs: refs(3, 3)},
|
||||
{ItemID: "item", Name: "Potion", Kind: dnd.ItemOccurrenceKindConsumed, From: " Aria ", SourceRefs: refs(4, 4)},
|
||||
{ItemID: "item", Name: "Moonblade", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "Borin", SourceRefs: refs(5, 5)},
|
||||
}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: dnd.ItemEventList{Events: []dnd.ItemEvent{}}})
|
||||
empty, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{}}})
|
||||
if err != nil || !empty.Approved {
|
||||
t.Fatalf("empty list result = %#v, %v", empty, err)
|
||||
}
|
||||
@@ -38,47 +37,47 @@ func TestValidatorRejectsOwnedSemanticBoundaries(t *testing.T) {
|
||||
negative := -1
|
||||
tests := []struct {
|
||||
name string
|
||||
value dnd.ItemEventList
|
||||
value dnd.ItemOccurrenceList
|
||||
want string
|
||||
}{
|
||||
{"missing events", dnd.ItemEventList{}, "events must be present"},
|
||||
{"blank name", listWith(dnd.ItemEvent{Name: " \t", Kind: dnd.ItemEventKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"},
|
||||
{"unsupported kind", listWith(dnd.ItemEvent{Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"},
|
||||
{"discovered holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"acquired without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"lost destination", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"consumed without holder", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer from", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer to", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"case equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"unicode equivalent transfer", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"zero quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"negative quantity", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"missing source refs", listWith(dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered}), "source_refs must contain"},
|
||||
{"missing events", dnd.ItemOccurrenceList{}, "occurrences must be present"},
|
||||
{"blank name", listWith(dnd.ItemOccurrence{ItemID: "item", Name: " \t", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: refs(1, 1)}), "name must not be empty"},
|
||||
{"unsupported kind", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: "unknown", SourceRefs: refs(1, 1)}), "kind is unsupported"},
|
||||
{"discovered holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, From: "Aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"acquired without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"lost destination", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindLost, From: "Aria", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"consumed without holder", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindConsumed, SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer from", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "party", To: "Borin", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"party transfer to", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: " PARTY ", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"case equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Aria", To: "aria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"unicode equivalent transfer", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindTransferred, From: "Åria", To: "Åria", SourceRefs: refs(1, 1)}), "incompatible"},
|
||||
{"zero quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &zero, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"negative quantity", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, Quantity: &negative, To: "party", SourceRefs: refs(1, 1)}), "quantity must be positive"},
|
||||
{"missing source refs", listWith(dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered}), "source_refs must contain"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: test.value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: test.value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || !strings.Contains(result.Message, test.want) {
|
||||
t.Fatalf("Validate() = %#v, %v; want %q", result, err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: valid}); err != nil || !result.Approved {
|
||||
if result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: valid}); err != nil || !result.Approved {
|
||||
t.Fatalf("valid result = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.T) {
|
||||
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)}
|
||||
for index := range value.Events {
|
||||
value.Events[index] = dnd.ItemEvent{Name: " \n", Kind: "unsupported"}
|
||||
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
|
||||
for index := range value.Occurrences {
|
||||
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: " \n", Kind: "unsupported"}
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
if value.Events[0].Name != " \n" {
|
||||
if value.Occurrences[0].Name != " \n" {
|
||||
t.Fatal("Validate() mutated input")
|
||||
}
|
||||
if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) {
|
||||
@@ -96,12 +95,12 @@ func TestValidatorAggregatesBoundedIndexedDiagnosticsAndRegistration(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func validList() dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
|
||||
func validList() dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: refs(1, 1)}}}
|
||||
}
|
||||
|
||||
func listWith(event dnd.ItemEvent) dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{event}}
|
||||
func listWith(event dnd.ItemOccurrence) dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{event}}
|
||||
}
|
||||
|
||||
func refs(start, end int) []source.SourceRef {
|
||||
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -34,9 +34,9 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
|
||||
return contracts.ValidationResult{}, fmt.Errorf("item event source-reference validator requires the current extraction chunk")
|
||||
return contracts.ValidationResult{}, fmt.Errorf("item occurrence source-reference validator requires the current extraction chunk")
|
||||
}
|
||||
if itemeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
@@ -47,21 +47,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
coverage = newChunkCoverage(req.Chunk)
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for eventIndex, event := range req.Value.Events {
|
||||
for eventIndex, event := range req.Value.Occurrences {
|
||||
for refIndex, ref := range event.SourceRefs {
|
||||
if err := index.ValidateRef(ref); err != nil {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: %s", eventIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
continue
|
||||
}
|
||||
if coverage != nil && !coverage.contains(req.Source, ref) {
|
||||
issues = append(issues, fmt.Sprintf("events[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||
issues = append(issues, fmt.Sprintf("occurrences[%d].source_refs[%d]: source reference is outside the current extraction chunk", eventIndex, refIndex))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item event source references", issues)}, nil
|
||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence source references", issues)}, nil
|
||||
}
|
||||
|
||||
type chunkCoverage struct {
|
||||
@@ -102,7 +102,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("valid result = %#v, %v", result, err)
|
||||
}
|
||||
value.Events[0].SourceRefs = []source.SourceRef{
|
||||
value.Occurrences[0].SourceRefs = []source.SourceRef{
|
||||
{SourceID: "foreign", StartUnitID: 1, EndUnitID: 1},
|
||||
{SourceID: "session", StartUnitID: 99, EndUnitID: 99},
|
||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 1},
|
||||
@@ -28,7 +28,7 @@ func TestValidatorOwnsSourceAndRangeValidation(t *testing.T) {
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
|
||||
t.Fatalf("invalid result = %#v, %v", result, err)
|
||||
}
|
||||
for _, index := range []string{"events[0].source_refs[0]", "events[0].source_refs[1]", "events[0].source_refs[2]"} {
|
||||
for _, index := range []string{"occurrences[0].source_refs[0]", "occurrences[0].source_refs[1]", "occurrences[0].source_refs[2]"} {
|
||||
if !strings.Contains(result.Message, index) {
|
||||
t.Fatalf("validation message = %q, missing %q", result.Message, index)
|
||||
}
|
||||
@@ -39,40 +39,40 @@ func TestValidatorEnforcesChunkOnlyDuringExtraction(t *testing.T) {
|
||||
doc := document()
|
||||
chunk := &source.Chunk{ID: "chunk-0", SourceID: doc.ID, Units: append([]source.SourceUnit(nil), doc.Units[:2]...)}
|
||||
contained := validList()
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: contained})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("contained evidence = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
crossesChunk := validList()
|
||||
crossesChunk.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk})
|
||||
crossesChunk.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 2, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: chunk, Value: crossesChunk})
|
||||
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||
t.Fatalf("out-of-chunk evidence = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
noncontiguousChunk := &source.Chunk{ID: "chunk-1", SourceID: doc.ID, Units: []source.SourceUnit{doc.Units[0], doc.Units[2]}}
|
||||
spansMissingUnit := validList()
|
||||
spansMissingUnit.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
|
||||
spansMissingUnit.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 3}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: doc, Chunk: noncontiguousChunk, Value: spansMissingUnit})
|
||||
if err != nil || result.Approved || !strings.Contains(result.Message, "outside the current extraction chunk") {
|
||||
t.Fatalf("partially contained evidence = %#v, %v", result, err)
|
||||
}
|
||||
|
||||
multiRange := validList()
|
||||
multiRange.Events[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: multiRange})
|
||||
multiRange.Occurrences[0].SourceRefs = []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 2}, {SourceID: doc.ID, StartUnitID: 3, EndUnitID: 4}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: multiRange})
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("post-merge evidence = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
|
||||
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()})
|
||||
_, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Stage: string(pipeline.StageExtract), Source: document(), Value: validList()})
|
||||
if err == nil || !strings.Contains(err.Error(), "requires the current extraction chunk") {
|
||||
t.Fatalf("Validate() error = %v", err)
|
||||
}
|
||||
malformed := dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring"}}}
|
||||
malformed := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring"}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), request(document(), malformed))
|
||||
if err != nil || !result.Approved {
|
||||
t.Fatalf("shape deferral = %#v, %v", result, err)
|
||||
@@ -80,12 +80,12 @@ func TestValidatorRequiresChunkDuringExtractionAndDefersShape(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||
value := dnd.ItemEventList{Events: make([]dnd.ItemEvent, 24)}
|
||||
for index := range value.Events {
|
||||
value.Events[index] = dnd.ItemEvent{Name: "Ring", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}}
|
||||
value := dnd.ItemOccurrenceList{Occurrences: make([]dnd.ItemOccurrence, 24)}
|
||||
for index := range value.Occurrences {
|
||||
value.Occurrences[index] = dnd.ItemOccurrence{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "foreign", StartUnitID: index + 1, EndUnitID: index + 1}}}
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), request(document(), value))
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "events[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) || !strings.Contains(result.Message, "occurrences[0].source_refs[0]") || !strings.Contains(result.Message, "additional issue(s) omitted") {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
before := validList()
|
||||
@@ -108,14 +108,14 @@ func TestValidatorBoundsDiagnosticsAndRegistration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func request(doc *source.SourceDocument, value dnd.ItemEventList) contracts.TypedValidationRequest[dnd.ItemEventList] {
|
||||
return contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value}
|
||||
func request(doc *source.SourceDocument, value dnd.ItemOccurrenceList) contracts.TypedValidationRequest[dnd.ItemOccurrenceList] {
|
||||
return contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value}
|
||||
}
|
||||
|
||||
func document() *source.SourceDocument {
|
||||
return &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}, {ID: 4}}}
|
||||
}
|
||||
|
||||
func validList() dnd.ItemEventList {
|
||||
return dnd.ItemEventList{Events: []dnd.ItemEvent{{Name: "Ring", Kind: dnd.ItemEventKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
|
||||
func validList() dnd.ItemOccurrenceList {
|
||||
return dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "Ring", Kind: dnd.ItemOccurrenceKindAcquired, To: "party", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 2}}}}}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const (
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.ItemEventList] = (*Validator)(nil)
|
||||
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
@@ -37,7 +37,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
|
||||
// Validate performs advisory source grounding only. Shape and range failures
|
||||
// are intentionally left to their blocking owners.
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemEventList]) (contracts.ValidationResult, error) {
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
|
||||
if itemeventshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -47,15 +47,15 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, event := range req.Value.Events {
|
||||
for index, event := range req.Value.Occurrences {
|
||||
citedText, err := resolver.CitedText(event.SourceRefs)
|
||||
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
Scope: fmt.Sprintf("events[%d]", index),
|
||||
Scope: fmt.Sprintf("occurrences[%d]", index),
|
||||
ReasonCode: WarningReasonCode,
|
||||
Message: fmt.Sprintf("item event name %s was not found in cited source text", diagnostics.Quote(event.Name)),
|
||||
Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(event.Name)),
|
||||
})
|
||||
}
|
||||
return contracts.ValidationResult{
|
||||
@@ -69,7 +69,7 @@ func Spec() pipeline.ValidatorSpec {
|
||||
}
|
||||
|
||||
func Register(registry *pipeline.ValidatorRegistry) error {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemEventListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemEventList], error) {
|
||||
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
|
||||
options, err := DecodeOptions(request.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -19,29 +19,28 @@ func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) {
|
||||
{ID: 2, Text: "of healing in the chest."},
|
||||
{ID: 3, Text: "The group rests."},
|
||||
}}
|
||||
value := dnd.ItemEventList{Events: []dnd.ItemEvent{
|
||||
{Name: "POTION OF HEALING", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
||||
{Name: "missing-item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
|
||||
value := dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "POTION OF HEALING", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}, {SourceID: doc.ID, StartUnitID: 2, EndUnitID: 2}}},
|
||||
{ItemID: "item", Name: "missing-item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
|
||||
}}
|
||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}},
|
||||
}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, References: references, Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, References: references, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
}
|
||||
if warning := result.Warnings[0]; warning.Scope != "events[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
|
||||
if warning := result.Warnings[0]; warning.Scope != "occurrences[1]" || warning.ReasonCode != WarningReasonCode || !strings.Contains(warning.Message, "missing-item") {
|
||||
t.Fatalf("warning = %#v", warning)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "a potion"}}}
|
||||
for _, value := range []dnd.ItemEventList{
|
||||
{Events: []dnd.ItemEvent{{Name: "", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
|
||||
{Events: []dnd.ItemEvent{{Name: "potion", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
|
||||
for _, value := range []dnd.ItemOccurrenceList{
|
||||
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}}},
|
||||
{Occurrences: []dnd.ItemOccurrence{{ItemID: "item", Name: "potion", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 99, EndUnitID: 99}}}}},
|
||||
} {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{Source: doc, Value: value})
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("deferred result = %#v, %v", result, err)
|
||||
}
|
||||
@@ -50,13 +49,13 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
||||
|
||||
func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) {
|
||||
count := diagnostics.MaxWarnings + 5
|
||||
events := make([]dnd.ItemEvent, count)
|
||||
events := make([]dnd.ItemOccurrence, count)
|
||||
for index := range events {
|
||||
events[index] = dnd.ItemEvent{Name: "missing item", Kind: dnd.ItemEventKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||
events[index] = dnd.ItemOccurrence{ItemID: "item", Name: "missing item", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemEventList]{
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{
|
||||
Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}},
|
||||
Value: dnd.ItemEventList{Events: events},
|
||||
Value: dnd.ItemOccurrenceList{Occurrences: events},
|
||||
})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != diagnostics.MaxWarnings || result.Warnings[len(result.Warnings)-1].ReasonCode != OmittedReasonCode {
|
||||
t.Fatalf("Validate() = %#v, %v", result, err)
|
||||
|
||||
@@ -1,170 +1,62 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/config"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
itemeventcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemevents"
|
||||
itemeventextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/itemevents"
|
||||
itemeventnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/itemevents"
|
||||
itemeventshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
|
||||
itemeventrefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/source_refs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
|
||||
)
|
||||
|
||||
func TestProductionItemEventPipelineProducesNormalizedDurableArtifact(t *testing.T) {
|
||||
raw := readItemEventFixture(t)
|
||||
func TestItemOccurrenceConfigurationRequiresEarlierItemRegistry(t *testing.T) {
|
||||
registries := productionNPCRegistries(t)
|
||||
resolved := resolveItemEventPipeline(t, registries)
|
||||
client := &itemEventProductionLLMClient{responses: [][]byte{[]byte(`{"events":[
|
||||
{"name":"Healing Potion","kind":"transferred","quantity":1,"from":"Aria","to":"Borin","source_refs":[{"start_segment":3,"end_segment":3}]},
|
||||
{"name":" Gold Pieces ","kind":"acquired","quantity":25,"to":" party ","source_refs":[{"start_segment":2,"end_segment":2}]},
|
||||
{"name":"Missing Relic","kind":"discovered","source_refs":[{"start_segment":4,"end_segment":4}]},
|
||||
{"name":"Ancient Coin","kind":"discovered","source_refs":[{"start_segment":1,"end_segment":1}]},
|
||||
{"name":"Gold Pieces","kind":"acquired","quantity":25,"to":"party","source_refs":[{"start_segment":2,"end_segment":2}]}
|
||||
]}`)}}
|
||||
|
||||
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: raw})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
if len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 {
|
||||
t.Fatalf("run output = %#v, rejected = %#v", output.NormalizeOutputs, output.Rejected)
|
||||
}
|
||||
serialized := normalizedLane(t, output, "item-events")
|
||||
if serialized.NormalizerKey != itemeventnormalize.Key || serialized.Artifact.Schema.ID != itemeventcodec.SchemaID || serialized.Artifact.Schema.Version != itemeventcodec.SchemaVersion {
|
||||
t.Fatalf("serialized item events = %#v", serialized)
|
||||
}
|
||||
events, err := itemeventcodec.New().Decode(serialized.Artifact.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("Decode(item event output) error = %v", err)
|
||||
}
|
||||
if len(events.Events) != 4 {
|
||||
t.Fatalf("item events = %#v, want one exact duplicate removed", events)
|
||||
}
|
||||
first, second, third, fourth := events.Events[0], events.Events[1], events.Events[2], events.Events[3]
|
||||
if first.Name != "Ancient Coin" || second.Name != "Gold Pieces" || second.Kind != dnd.ItemEventKindAcquired || second.Quantity == nil || *second.Quantity != 25 || second.To != "party" || third.Name != "Healing Potion" || third.Kind != dnd.ItemEventKindTransferred || third.From != "Aria" || third.To != "Borin" || fourth.Name != "Missing Relic" {
|
||||
t.Fatalf("normalized item event sequence = %#v", events.Events)
|
||||
}
|
||||
for _, event := range events.Events {
|
||||
for _, ref := range event.SourceRefs {
|
||||
if ref.SourceID != "item-events-session" {
|
||||
t.Fatalf("source reference = %#v, want current transcript source", ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasItemEventWarning(output.Warnings, "item_event_source_unrelated") || !hasItemEventWarning(output.Warnings, "duplicate_item_event_collapsed") {
|
||||
t.Fatalf("warnings = %#v, want advisory relatedness and duplicate-collapse warnings", output.Warnings)
|
||||
}
|
||||
if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 {
|
||||
t.Fatalf("manifest = %#v", output.Manifest)
|
||||
}
|
||||
lane := output.Manifest.ArtifactLanes[0]
|
||||
if lane.ID != "item-events" || lane.Extractor != itemeventextract.Key || lane.Merger != pipeline.DefaultMergeModule || lane.Normalizer != itemeventnormalize.Key {
|
||||
t.Fatalf("manifest lane = %#v", lane)
|
||||
}
|
||||
if len(client.requests) != 1 || client.requests[0].PromptID != itemeventextract.PromptID {
|
||||
t.Fatalf("LLM requests = %#v", client.requests)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProductionItemEventPipelineRetriesBlockingCandidates(t *testing.T) {
|
||||
registries := productionNPCRegistries(t)
|
||||
resolved := resolveItemEventPipeline(t, registries)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
response string
|
||||
reasonCode string
|
||||
validatorName string
|
||||
}{
|
||||
{name: "shape", response: `{"events":[{"name":"","kind":"discovered","source_refs":[{"start_segment":1,"end_segment":1}]}]}`, reasonCode: itemeventshape.ReasonCode, validatorName: itemeventshape.Key},
|
||||
{name: "source references", response: `{"events":[{"name":"Ancient Coin","kind":"discovered","source_refs":[{"start_segment":99,"end_segment":99}]}]}`, reasonCode: itemeventrefs.ReasonCode, validatorName: itemeventrefs.Key},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &itemEventProductionLLMClient{responses: [][]byte{[]byte(test.response), []byte(test.response), []byte(test.response)}}
|
||||
output, err := runPreparedPipeline(t, registries, resolved, client, pipeline.RunInput{RawInput: readItemEventFixture(t)})
|
||||
if err != nil {
|
||||
t.Fatalf("Run() error = %v; want rejected candidate outcome", err)
|
||||
}
|
||||
if len(output.NormalizeOutputs) != 0 || len(output.Rejected) != 1 || len(client.requests) != 3 {
|
||||
t.Fatalf("output=%#v rejected=%#v requests=%d", output.NormalizeOutputs, output.Rejected, len(client.requests))
|
||||
}
|
||||
rejection := output.Rejected[0]
|
||||
if rejection.ReasonCode != test.reasonCode || rejection.ValidatorName != test.validatorName || rejection.AttemptCount != 3 {
|
||||
t.Fatalf("rejection = %#v", rejection)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func resolveItemEventPipeline(t *testing.T, registries pipeline.Registries) pipeline.ResolvedPipeline {
|
||||
t.Helper()
|
||||
configValue := config.Default()
|
||||
configValue.Pipelines["dnd-item-events-fixture"] = pipeline.PipelineProfile{
|
||||
Input: pipeline.Binding(transcript.Key),
|
||||
Chunk: pipeline.ModuleBinding{Module: pipeline.DefaultChunkModule, Options: map[string]any{"max_units": 100}},
|
||||
Artifacts: map[string]pipeline.ArtifactLaneProfile{
|
||||
"item-events": {
|
||||
Extract: pipeline.ModuleBinding{Module: itemeventextract.Key, Retries: 2},
|
||||
Normalize: pipeline.Binding(itemeventnormalize.Key),
|
||||
},
|
||||
},
|
||||
}
|
||||
effective, err := configValue.Resolve(config.ResolveInput{PipelineID: "dnd-item-events-fixture", Catalog: moduleCatalog(registries)})
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
return effective.ResolvedPipeline
|
||||
}
|
||||
|
||||
func readItemEventFixture(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("testdata/seriatim_item_events_session.json")
|
||||
cfg, err := configFromYAML(`
|
||||
version: 4
|
||||
pipelines:
|
||||
items:
|
||||
input: seriatim
|
||||
chunk: generic
|
||||
steps:
|
||||
- id: identify-items
|
||||
artifacts:
|
||||
registry:
|
||||
extract: dnd/item-registry
|
||||
merge: appendorder
|
||||
normalize: dnd/item-registry
|
||||
- id: record-occurrences
|
||||
references:
|
||||
item_registry:
|
||||
artifact:
|
||||
step: identify-items
|
||||
lane: registry
|
||||
artifacts:
|
||||
occurrences:
|
||||
extract: dnd/item-events
|
||||
merge: appendorder
|
||||
normalize: dnd/item-events
|
||||
`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
if _, err := cfg.Resolve(config.ResolveInput{PipelineID: "items", Catalog: moduleCatalog(registries)}); err != nil {
|
||||
t.Fatalf("Resolve() error = %v", err)
|
||||
}
|
||||
|
||||
func hasItemEventWarning(warnings []contracts.Warning, reasonCode string) bool {
|
||||
for _, warning := range warnings {
|
||||
if warning.ReasonCode == reasonCode && strings.HasPrefix(warning.Scope, "events[") {
|
||||
return true
|
||||
}
|
||||
broken, err := configFromYAML(strings.Replace(`
|
||||
version: 4
|
||||
pipelines:
|
||||
items:
|
||||
input: seriatim
|
||||
chunk: generic
|
||||
steps:
|
||||
- id: record-occurrences
|
||||
artifacts:
|
||||
occurrences:
|
||||
extract: dnd/item-events
|
||||
`, "record-occurrences", "record-occurrences", 1))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := broken.Resolve(config.ResolveInput{PipelineID: "items", Catalog: moduleCatalog(registries)}); err == nil {
|
||||
t.Fatal("Resolve() error = nil, want required item registry")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type itemEventProductionLLMClient struct {
|
||||
responses [][]byte
|
||||
requests []contracts.StructuredCompletionRequest
|
||||
}
|
||||
|
||||
func (client *itemEventProductionLLMClient) CompleteStructured(ctx context.Context, request contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, err
|
||||
}
|
||||
if request.PromptID != itemeventextract.PromptID {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
|
||||
}
|
||||
index := len(client.requests)
|
||||
if index >= len(client.responses) {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("missing item event response %d", index)
|
||||
}
|
||||
content := append([]byte(nil), client.responses[index]...)
|
||||
if err := json.Unmarshal(content, out); err != nil {
|
||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate item event structured target: %w", err)
|
||||
}
|
||||
client.requests = append(client.requests, request)
|
||||
return contracts.StructuredCompletionResponse{Content: content, Provider: "test", Model: "item-events-fake"}, nil
|
||||
}
|
||||
|
||||
var _ contracts.StructuredLLMClient = (*itemEventProductionLLMClient)(nil)
|
||||
|
||||
Reference in New Issue
Block a user