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 not an inventory or ledger: do not calculate balances, resolve item identity
across records, or infer ownership that the transcript does not establish. across records, or infer ownership that the transcript does not establish.
For every occurrence, copy the exact `item_id` and `name` pair from the supplied For every occurrence, use the supplied canonical item `name`. Record a stated
item registry. Record a stated quantity as an integer and leave it null when the transcript quantity as an integer and leave it null when the transcript does not state
does not state one. Use a concise observed item name and preserve the stated one. Use a concise observed item name and preserve the stated currency
currency denomination. denomination.
Use `discovered` when the party learns of or encounters an item without Use `discovered` when the party learns of or encounters an item without
establishing possession. Use `acquired` when the party or a party member gains 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 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, must use one registry item's canonical `name`; do not invent, rename, merge,
rename, merge, or infer registry items. The registry is not transcript or infer registry items. The registry is not transcript evidence: cite only the
evidence: cite only the current transcript chunk in `source_refs`. current transcript chunk in `source_refs`.
{{ input "item_registry" }} {{ input "item_registry" }}

View File

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

View File

@@ -230,7 +230,7 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
requiresIDs bool requiresIDs bool
}{ }{
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"}, {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) requests := client.requestsFor(test.promptID)
if len(requests) != 2 { if len(requests) != 2 {
@@ -321,16 +321,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
} else { } else {
var registry struct { var registry struct {
Items []struct { Items []struct {
ID string `json:"id"` Name string `json:"name"`
} `json:"items"` } `json:"items"`
} }
if err := json.Unmarshal(request.Inputs["item_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["item_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err) 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)) 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: case combat.PromptID:
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`) content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
@@ -338,16 +338,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
if combatScene { if combatScene {
var registry struct { var registry struct {
NPCs []struct { NPCs []struct {
ID string `json:"id"` Name string `json:"name"`
} `json:"npcs"` } `json:"npcs"`
} }
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil { if err := json.Unmarshal(request.Inputs["npc_registry"].Content, &registry); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err) 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") 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 { } else {
content = []byte(`{"occurrences":[]}`) content = []byte(`{"occurrences":[]}`)
} }

View File

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

View File

@@ -20,7 +20,7 @@ const (
ItemRegistryMaxBytes = itemregistry.MaxBytes ItemRegistryMaxBytes = itemregistry.MaxBytes
) )
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v1" const mappingPolicy = "dnd.item_occurrences.extract_mapping.v2"
var requiredCapabilities = []string{ var requiredCapabilities = []string{
"chunks", "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 { 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]{}, 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 { func ModuleSpec() pipeline.ModuleSpec {

View File

@@ -12,7 +12,7 @@ import (
func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) { func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
id := itemidentity.DeriveID("Torch") id := itemidentity.DeriveID("Torch")
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ 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 := extractionRequest()
req.References = itemRegistryReferences(t) req.References = itemRegistryReferences(t)
@@ -24,55 +24,38 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
t.Fatalf("occurrences = %#v", result.Value.Occurrences) t.Fatalf("occurrences = %#v", result.Value.Occurrences)
} }
input := client.requests[0].Inputs[ItemRegistryReferenceSlot] input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" { if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") {
t.Fatalf("registry prompt input = %#v", input) t.Fatalf("registry prompt input = %#v, want names-only projection", input)
} }
} }
func TestExtractRejectsInvalidRegistryPairs(t *testing.T) { func TestExtractCanonicalizesComparisonEquivalentNames(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) {
id := itemidentity.DeriveID("Torch") id := itemidentity.DeriveID("Torch")
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{ 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)},
{ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
}}} }}}
req := extractionRequest() req := extractionRequest()
req.References = itemRegistryReferences(t) req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
if err == nil || !strings.Contains(err.Error(), "occurrences[1].item_id") { if err != nil {
t.Fatalf("Extract() error = %v, want occurrence index and item ID", err) 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 { if len(result.Value.Occurrences) != 0 {
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences) 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) { func TestExtractPreservesNullableFields(t *testing.T) {
id := itemidentity.DeriveID("Torch") client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
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 := extractionRequest()
req.References = itemRegistryReferences(t) req.References = itemRegistryReferences(t)
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req) result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)

View File

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

View File

@@ -34,7 +34,7 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
"players": promptkit.Inline("item-occurrence-player"), "players": promptkit.Inline("item-occurrence-player"),
"party": promptkit.Inline(" "), "party": promptkit.Inline(" "),
"glossary": 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 { 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}}}}} 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 { func testSourceRefs() []source.SourceRef {
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}} 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{ valid := map[string]any{"occurrences": []any{
map[string]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}}, "source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
}, },
map[string]any{ 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}}, "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 occurrences", map[string]any{}},
{"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}}, {"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}},
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}}, {"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 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}})}}}, {"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}})}}}, {"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 { func responseOccurrence() map[string]any {
return 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 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. // projection, including for an unbound or empty registry.
func (r *Registry) ProjectionDigest() string { func (r *Registry) ProjectionDigest() string {
if r == nil { if r == nil {
@@ -202,8 +202,8 @@ func (r *Registry) Count() int {
return len(r.list.Items) return len(r.list.Items)
} }
// PromptInput returns the ordered ID/name registry projection as a content-safe // PromptInput returns the ordered names-only registry projection as a
// prompt input. Evidence and reference provenance are omitted. // content-safe prompt input. Durable IDs, evidence, and provenance are omitted.
func (r *Registry) PromptInput() contracts.LLMInputMaterial { func (r *Registry) PromptInput() contracts.LLMInputMaterial {
if r == nil { if r == nil {
return contracts.LLMInputMaterial{} return contracts.LLMInputMaterial{}
@@ -242,7 +242,6 @@ func semanticDigest(content []byte) string {
} }
type projectedItem struct { type projectedItem struct {
ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }
@@ -253,7 +252,7 @@ type projectedItemRegistry struct {
func promptProjection(list dnd.ItemRegistry) ([]byte, error) { func promptProjection(list dnd.ItemRegistry) ([]byte, error) {
projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))} projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))}
for index, item := range 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) 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()) registry := resolveRegistry(t, fixture())
if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 { if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count()) 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 { if got := string(registry.PromptInput().Content); got != want {
t.Fatalf("prompt projection = %s, want %s", 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) { if strings.Contains(string(registry.PromptInput().Content), forbidden) {
t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content) 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" { if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" {
t.Fatalf("LookupID() = %#v, %t", item, ok) 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 := registry.Items()
items[0].Name = "changed" items[0].Name = "changed"