Adopt registry-backed item occurrences

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,18 +1,25 @@
// Package itemevents extracts source-grounded D&D item events.
// Package itemevents extracts source-grounded D&D item occurrences.
package itemevents
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...)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,13 +17,13 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
if schema.Key != ResponseSchemaKey || schema.ID != ResponseSchemaID || schema.Name != ResponseSchemaName || schema.Version != SchemaVersion || !strings.HasPrefix(schema.SHA256, "sha256:") || !json.Valid(schema.JSONSchema) {
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{},
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ func registerModules(registries pipeline.Registries) error {
{name: "npc registry codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
{name: "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) }},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
// Package registry validates item occurrence identity against item grounding.
package registry
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
occurrenceshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemevents/shape"
)
const (
Key = "extract/dnd/item-events/registry"
ReasonCode = "invalid_item_occurrence_registry"
policy = "dnd.item_occurrences.validator.registry.v1"
)
type Options struct{}
type Validator struct{ itemResolver *itemregistry.Resolver }
var _ contracts.TypedValidator[dnd.ItemOccurrenceList] = (*Validator)(nil)
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
func New(_ Options, references ...contracts.ReferenceSet) (*Validator, error) {
if len(references) > 1 {
return nil, fmt.Errorf("item occurrence registry validator accepts at most one reference set")
}
var referenceSet contracts.ReferenceSet
if len(references) == 1 {
referenceSet = references[0]
}
resolver, err := itemregistry.NewResolver(referenceSet)
if err != nil {
return nil, fmt.Errorf("prepare item registry: %w", err)
}
return &Validator{itemResolver: resolver}, nil
}
func (v *Validator) Name() string { return Key }
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
return contracts.ExecutionClassDeterministic
}
func (v *Validator) ManifestMetadata() map[string]any {
if v == nil || v.itemResolver == nil {
return nil
}
metadata := map[string]any{"policy": policy}
seeded := v.itemResolver.Seeded()
if seeded.Bound() {
metadata["item_registry_digest"] = seeded.Digest()
metadata["item_count"] = seeded.Count()
}
return metadata
}
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if v == nil || v.itemResolver == nil {
return nil
}
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "item_registry", Value: v.itemResolver.Seeded().ProjectionDigest()}}
}
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.ItemOccurrenceList]) (contracts.ValidationResult, error) {
if occurrenceshape.Validate(req.Value) != nil {
return contracts.ValidationResult{Approved: true}, nil
}
if v == nil || v.itemResolver == nil {
return contracts.ValidationResult{}, fmt.Errorf("item occurrence registry validator must not be nil")
}
registry, err := v.itemResolver.Resolve(req.References)
if err != nil {
return contracts.ValidationResult{}, fmt.Errorf("resolve item registry: %w", err)
}
if !registry.Bound() {
return rejection([]string{"item registry reference is required"}), nil
}
issues := make([]string, 0)
for index, occurrence := range req.Value.Occurrences {
item, ok := registry.LookupID(occurrence.ItemID)
if !ok {
issues = append(issues, fmt.Sprintf("occurrences[%d].item_id is not in the item registry: %s", index, diagnostics.Quote(occurrence.ItemID)))
continue
}
if occurrence.Name != item.Name {
issues = append(issues, fmt.Sprintf("occurrences[%d] does not match registry item %s", index, diagnostics.Quote(occurrence.ItemID)))
}
}
if len(issues) == 0 {
return contracts.ValidationResult{Approved: true}, nil
}
return rejection(issues), nil
}
func rejection(issues []string) contracts.ValidationResult {
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: diagnostics.Aggregate("invalid item occurrence registry", issues)}
}
func Spec() pipeline.ValidatorSpec {
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
}
func Register(registry *pipeline.ValidatorRegistry) error {
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.ItemOccurrenceListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.ItemOccurrenceList], error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(options, request.References)
})
}
func DecodeOptions(options map[string]any) (Options, error) {
if err := pipeline.RejectUnknownOptions(options); err != nil {
return Options{}, err
}
return Options{}, nil
}
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }

View File

@@ -0,0 +1,37 @@
package registry
import (
"context"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
itemcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/itemregistry"
itemidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
itemregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/registry"
)
func TestValidatorRejectsUnknownAndMismatchedItemPairs(t *testing.T) {
id := itemidentity.DeriveID("Torch")
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{{ID: id, Name: "Torch", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}}})
if err != nil {
t.Fatal(err)
}
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{itemregistry.ReferenceSlot: {Slot: contracts.ReferenceSlot{Name: itemregistry.ReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: itemregistry.ReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
validator, err := New(Options{}, references)
if err != nil {
t.Fatal(err)
}
base := dnd.ItemOccurrence{ItemID: id, Name: "Torch", Kind: dnd.ItemOccurrenceKindDiscovered, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
for _, occurrence := range []dnd.ItemOccurrence{{ItemID: "unknown", Name: "Unknown", Kind: base.Kind, SourceRefs: base.SourceRefs}, {ItemID: id, Name: "Lantern", Kind: base.Kind, SourceRefs: base.SourceRefs}} {
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{occurrence}}})
if err != nil || result.Approved || result.ReasonCode != ReasonCode {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}
result, err := validator.Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{References: references, Value: dnd.ItemOccurrenceList{Occurrences: []dnd.ItemOccurrence{base}}})
if err != nil || !result.Approved {
t.Fatalf("Validate() = %#v, %v", result, err)
}
}

View File

@@ -22,7 +22,7 @@ const (
type Options struct{}
type 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

View File

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

View File

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

View File

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

View File

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

View File

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