Ground item occurrences by canonical names

This commit is contained in:
2026-08-08 14:43:04 +00:00
parent ece1bca460
commit 8e680cf96e
13 changed files with 112 additions and 93 deletions

View File

@@ -3,10 +3,10 @@ in party possession established by the transcript. This is an occurrence history
not an inventory or ledger: do not calculate balances, resolve item identity
across records, or infer ownership that the transcript does not establish.
For every occurrence, copy the exact `item_id` and `name` pair from the supplied
item registry. Record a stated quantity as an integer and leave it null when the transcript
does not state one. Use a concise observed item name and preserve the stated
currency denomination.
For every occurrence, use the supplied canonical item `name`. Record a stated
quantity as an integer and leave it null when the transcript does not state
one. Use a concise observed item name and preserve the stated currency
denomination.
Use `discovered` when the party learns of or encounters an item without
establishing possession. Use `acquired` when the party or a party member gains

View File

@@ -1,6 +1,6 @@
Use the supplied item registry only to ground each occurrence. Every record
must copy one registry item's exact `id` and exact `name`; do not invent,
rename, merge, or infer registry items. The registry is not transcript
evidence: cite only the current transcript chunk in `source_refs`.
must use one registry item's canonical `name`; do not invent, rename, merge,
or infer registry items. The registry is not transcript evidence: cite only the
current transcript chunk in `source_refs`.
{{ input "item_registry" }}

View File

@@ -10,9 +10,8 @@
"items": {
"type": "object",
"additionalProperties": false,
"required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"],
"required": ["name", "kind", "quantity", "from", "to", "source_refs"],
"properties": {
"item_id": {"type": "string"},
"name": {"type": "string"},
"kind": {"type": "string"},
"quantity": {"type": ["integer", "null"]},

View File

@@ -230,7 +230,7 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
requiresIDs bool
}{
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"},
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade", requiresIDs: true},
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"},
} {
requests := client.requestsFor(test.promptID)
if len(requests) != 2 {
@@ -321,16 +321,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
} else {
var registry struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"items"`
}
if err := json.Unmarshal(request.Inputs["item_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err)
}
if len(registry.Items) != 1 {
if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
}
content = []byte(fmt.Sprintf(`{"occurrences":[{"item_id":%q,"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`, registry.Items[0].ID))
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`)
}
case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
@@ -338,16 +338,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
if combatScene {
var registry struct {
NPCs []struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"npcs"`
}
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err)
}
if len(registry.NPCs) == 0 {
if len(registry.NPCs) == 0 || registry.NPCs[0].Name != "Kesh" {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs")
}
content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID))
content = []byte(`{"occurrences":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
} else {
content = []byte(`{"occurrences":[]}`)
}

View File

@@ -22,9 +22,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
}
ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences))
for index := range response.Occurrences {
if err := validateRegistryPair(index, response.Occurrences[index], registry); err != nil {
return err
canonical, found := registry.Lookup(response.Occurrences[index].Name)
if !found {
return fmt.Errorf("occurrences[%d].name is not in the item registry", index)
}
response.Occurrences[index].Name = canonical.Name
earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID)
ordered[index] = orderedItemOccurrenceResponse{
value: response.Occurrences[index],
@@ -56,26 +58,19 @@ func canonicalizeItemOccurrence(occurrence *itemOccurrenceResponse, order shared
return order.EarliestValid(refs)
}
func validateRegistryPair(index int, occurrence itemOccurrenceResponse, registry *itemregistry.Registry) error {
item, found := registry.LookupID(occurrence.ItemID)
if !found {
return fmt.Errorf("occurrences[%d].item_id is not in the item registry", index)
}
if item.Name != occurrence.Name {
return fmt.Errorf("occurrences[%d].name does not match item_id", index)
}
return nil
}
func canonicalItemOccurrenceList(response extractionResponse, sourceID string) dnd.ItemOccurrenceList {
func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) {
if response.Occurrences == nil {
return dnd.ItemOccurrenceList{}
return dnd.ItemOccurrenceList{}, nil
}
occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences))
for _, occurrence := range response.Occurrences {
for index, occurrence := range response.Occurrences {
item, found := registry.Lookup(occurrence.Name)
if !found {
return dnd.ItemOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the item registry", index)
}
occurrences = append(occurrences, dnd.ItemOccurrence{
ItemID: occurrence.ItemID,
Name: occurrence.Name,
ItemID: item.ID,
Name: item.Name,
Kind: dnd.ItemOccurrenceKind(occurrence.Kind),
Quantity: cloneQuantity(occurrence.Quantity),
From: occurrence.From,
@@ -83,7 +78,7 @@ func canonicalItemOccurrenceList(response extractionResponse, sourceID string) d
SourceRefs: itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID),
})
}
return dnd.ItemOccurrenceList{Occurrences: occurrences}
return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil
}
func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef {

View File

@@ -20,7 +20,7 @@ const (
ItemRegistryMaxBytes = itemregistry.MaxBytes
)
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v1"
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v2"
var requiredCapabilities = []string{
"chunks",
@@ -164,7 +164,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if err := canonicalizeResponse(&response, order, req.Source.ID, registry); err != nil {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err)
}
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID)}, nil
value, err := canonicalItemOccurrenceList(response, req.Source.ID, registry)
if err != nil {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("resolve canonical item occurrence names: %w", err)
}
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil
}
func ModuleSpec() pipeline.ModuleSpec {

View File

@@ -12,7 +12,7 @@ import (
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)},
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
}}}
req := extractionRequest()
req.References = itemRegistryReferences(t)
@@ -24,55 +24,38 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
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)
if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") {
t.Fatalf("registry prompt input = %#v, want names-only projection", input)
}
}
func TestExtractRejectsInvalidRegistryPairs(t *testing.T) {
id := itemidentity.DeriveID("Torch")
for _, test := range []struct {
name string
occurrence itemOccurrenceResponse
wantError string
}{
{
name: "unknown item ID",
occurrence: itemOccurrenceResponse{ItemID: "unknown", Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
wantError: "occurrences[0].item_id is not in the item registry",
},
{
name: "mismatched item name",
occurrence: itemOccurrenceResponse{ItemID: id, Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
wantError: "occurrences[0].name does not match item_id",
},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{test.occurrence}}}
req := extractionRequest()
req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), test.wantError) {
t.Fatalf("Extract() error = %v, want %q", err, test.wantError)
}
if len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences)
}
})
}
}
func TestExtractRejectsResponseWithInvalidRegistryPairAfterValidOccurrence(t *testing.T) {
func TestExtractCanonicalizesComparisonEquivalentNames(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)},
{Name: " tORCH ", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
}}}
req := extractionRequest()
req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "occurrences[1].item_id") {
t.Fatalf("Extract() error = %v, want occurrence index and item ID", err)
if err != nil {
t.Fatal(err)
}
occurrence := result.Value.Occurrences[0]
if occurrence.ItemID != id || occurrence.Name != "Torch" {
t.Fatalf("canonical occurrence = %#v", occurrence)
}
}
func TestExtractRejectsUnknownNameAfterValidOccurrence(t *testing.T) {
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
{Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
}}}
req := extractionRequest()
req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "occurrences[1].name is not in the item registry") {
t.Fatalf("Extract() error = %v, want occurrence index and unknown name", err)
}
if len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences)
@@ -86,9 +69,36 @@ func TestExtractRequiresItemRegistry(t *testing.T) {
}
}
func TestExtractAcceptsOnlyEmptyResponseForEmptyRegistry(t *testing.T) {
references := emptyItemRegistryReferences(t)
for _, test := range []struct {
name string
response extractionResponse
wantErr bool
}{
{name: "empty", response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}},
{name: "selection", response: extractionResponse{Occurrences: []itemOccurrenceResponse{{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}}}, wantErr: true},
} {
t.Run(test.name, func(t *testing.T) {
client := &fakeItemOccurrencesLLMClient{response: test.response}
req := extractionRequest()
req.References = references
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
if test.wantErr {
if err == nil || !strings.Contains(err.Error(), "name is not in the item registry") || len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err)
}
return
}
if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() = %#v, %v; want present empty occurrences", result, err)
}
})
}
}
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}]}]}`)}
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"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)

View File

@@ -5,7 +5,6 @@ type extractionResponse struct {
}
type itemOccurrenceResponse struct {
ItemID string `json:"item_id"`
Name string `json:"name"`
Kind string `json:"kind"`
Quantity *int `json:"quantity,omitempty"`

View File

@@ -34,7 +34,7 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
"players": promptkit.Inline("item-occurrence-player"),
"party": promptkit.Inline(" "),
"glossary": promptkit.Inline(" "),
"item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`),
"item_registry": promptkit.Inline(`{"items":[{"name":"Torch"}]}`),
},
})
if err != nil {

View File

@@ -21,6 +21,15 @@ func itemRegistryReferences(t *testing.T) contracts.ReferenceSet {
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 emptyItemRegistryReferences(t *testing.T) contracts.ReferenceSet {
t.Helper()
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{}})
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 testSourceRefs() []source.SourceRef {
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}
}

View File

@@ -19,11 +19,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
}
valid := map[string]any{"occurrences": []any{
map[string]any{
"item_id": "item:sha256:test", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
"name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
},
map[string]any{
"item_id": "item:sha256:test", "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
"name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
},
}}
@@ -41,6 +41,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
{"missing occurrences", map[string]any{}},
{"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}},
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}},
{"opaque item identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "item_id", "item:sha256:opaque")}}},
{"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}},
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
{"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
@@ -68,7 +69,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
func responseOccurrence() map[string]any {
return map[string]any{
"item_id": "item:sha256:test", "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
"name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
}
}

View File

@@ -185,7 +185,7 @@ func (r *Registry) Digest() string {
return r.digest
}
// ProjectionDigest returns the SHA-256 digest of the exact ID/name prompt
// ProjectionDigest returns the SHA-256 digest of the exact names-only prompt
// projection, including for an unbound or empty registry.
func (r *Registry) ProjectionDigest() string {
if r == nil {
@@ -202,8 +202,8 @@ func (r *Registry) Count() int {
return len(r.list.Items)
}
// PromptInput returns the ordered ID/name registry projection as a content-safe
// prompt input. Evidence and reference provenance are omitted.
// PromptInput returns the ordered names-only registry projection as a
// content-safe prompt input. Durable IDs, evidence, and provenance are omitted.
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
if r == nil {
return contracts.LLMInputMaterial{}
@@ -242,7 +242,6 @@ func semanticDigest(content []byte) string {
}
type projectedItem struct {
ID string `json:"id"`
Name string `json:"name"`
}
@@ -253,7 +252,7 @@ type projectedItemRegistry struct {
func promptProjection(list dnd.ItemRegistry) ([]byte, error) {
projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))}
for index, item := range list.Items {
projection.Items[index] = projectedItem{ID: item.ID, Name: item.Name}
projection.Items[index] = projectedItem{Name: item.Name}
}
return json.Marshal(projection)
}

View File

@@ -25,16 +25,16 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
}
}
func TestResolveProjectsOrderedIDsAndNamesWithoutEvidence(t *testing.T) {
func TestResolveProjectsOrderedNamesWithoutEvidence(t *testing.T) {
registry := resolveRegistry(t, fixture())
if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count())
}
want := `{"items":[{"id":"` + identity.DeriveID("Silver Key") + `","name":"Silver Key"},{"id":"` + identity.DeriveID("Healer's Kit") + `","name":"Healer's Kit"}]}`
want := `{"items":[{"name":"Silver Key"},{"name":"Healer's Kit"}]}`
if got := string(registry.PromptInput().Content); got != want {
t.Fatalf("prompt projection = %s, want %s", got, want)
}
for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} {
for _, forbidden := range []string{"item:sha256:", "source_refs", "source_id", "session-alpha"} {
if strings.Contains(string(registry.PromptInput().Content), forbidden) {
t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content)
}
@@ -52,6 +52,9 @@ func TestRegistryAccessorsAndLookupsAreDefensive(t *testing.T) {
if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" {
t.Fatalf("LookupID() = %#v, %t", item, ok)
}
if _, ok := registry.Lookup("Unknown Item"); ok {
t.Fatal("Lookup() accepted an unknown item")
}
items := registry.Items()
items[0].Name = "changed"