Simplify contextual entity grounding

This commit is contained in:
2026-08-08 15:47:41 +00:00
parent 20397ef710
commit d9b87347b8
6 changed files with 45 additions and 89 deletions

View File

@@ -5,8 +5,8 @@ across records, or infer ownership that the transcript does not establish.
For every occurrence, use the supplied canonical item `name`. Record a stated 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 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 one. Preserve the stated currency denomination through the selected canonical
denomination. registry name.
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

@@ -274,8 +274,9 @@ path.
registry and the current `*source.SourceDocument`. Its API must provide: registry and the current `*source.SourceDocument`. Its API must provide:
- a cloned `contracts.LLMInputMaterial` for the `location_registry` slot; - a cloned `contracts.LLMInputMaterial` for the `location_registry` slot;
- deterministic resolution of a returned selector to one cloned - deterministic resolution of a returned selector to one cloned
`dnd.Location`; and `dnd.Location`.
- the digest of the exact model projection. The prompt material's existing `Digest` field owns the digest of the exact
model projection; do not expose a second grounding-specific digest API.
4. Construct the projection in registry order. Group entries by the existing 4. Construct the projection in registry order. Group entries by the existing
location comparison key: location comparison key:
- every projection entry has exactly the required fields `name`, - every projection entry has exactly the required fields `name`,
@@ -299,10 +300,10 @@ path.
7. Separate deterministic identity fingerprinting from LLM material. Add 7. Separate deterministic identity fingerprinting from LLM material. Add
`IdentityDigest()` over the registry's ordered `{id,name}` identity `IdentityDigest()` over the registry's ordered `{id,name}` identity
projection, and update the location normalizer and registry-validator projection, and update the location normalizer and registry-validator
checkpoint consumers to use it. The new operation grounding owns the model checkpoint consumers to use it. The new operation grounding carries the
projection digest. Retain the old ID-bearing prompt accessor only as a model projection digest in its `LLMInputMaterial`. Retain the old ID-bearing
documented transitional dependency of the still-unchanged location prompt accessor only as a documented transitional dependency of the
occurrence extractor; do not add new callers. still-unchanged location occurrence extractor; do not add new callers.
8. Add focused tests for unique names, same-name context and selectors, 8. Add focused tests for unique names, same-name context and selectors,
canonical range order, deterministic projection/digest, defensive copies, canonical range order, deterministic projection/digest, defensive copies,
exact selector resolution, nil/foreign/invalid references, selector exact selector resolution, nil/foreign/invalid references, selector
@@ -367,10 +368,12 @@ remove durable location IDs from the prompt and private response.
7. Change `mappingPolicy` to 7. Change `mappingPolicy` to
`dnd.location_occurrences.extract_mapping.v2`. `dnd.location_occurrences.extract_mapping.v2`.
8. Remove the legacy ID-bearing registry `PromptInput` and its model-projection 8. Remove the legacy ID-bearing registry `PromptInput` and its model-projection
digest once the extractor uses operation grounding. Update the occurrence digest once the extractor uses operation grounding. Keep the occurrence's
checkpoint to combine `IdentityDigest()` with the new grounding projection static module fingerprint based on `IdentityDigest()`, prompt/schema
digest, prompt/schema fingerprint, mapping policy, and its existing inputs; fingerprints, and mapping policy. The operation-scoped projection is already
do not retain dead compatibility aliases. covered by source/chunk identity and configured or generated reference
dependencies, while its `LLMInputMaterial.Digest` identifies the exact model
input; do not add a second digest API or operation-aware static fingerprint.
9. Update focused schema, prompt, extractor, canonicalization, checkpoint, and 9. Update focused schema, prompt, extractor, canonicalization, checkpoint, and
generated-reference tests. Cover unique-name empty selectors, successful generated-reference tests. Cover unique-name empty selectors, successful
same-name selection, failure for an unsupported ambiguous mention, same-name selection, failure for an unsupported ambiguous mention,

View File

@@ -10,26 +10,34 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
type orderedItemOccurrenceResponse struct { type orderedItemOccurrence struct {
value itemOccurrenceResponse value dnd.ItemOccurrence
earliest int earliest int
hasEvidence bool hasEvidence bool
} }
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) error { func canonicalItemOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) {
if response == nil { if response.Occurrences == nil {
return nil return dnd.ItemOccurrenceList{}, nil
} }
ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences)) ordered := make([]orderedItemOccurrence, len(response.Occurrences))
for index := range response.Occurrences { for index, occurrence := range response.Occurrences {
canonical, found := registry.Lookup(response.Occurrences[index].Name) item, found := registry.Lookup(occurrence.Name)
if !found { if !found {
return fmt.Errorf("occurrences[%d].name is not in the item registry", index) return dnd.ItemOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the item registry", index)
} }
response.Occurrences[index].Name = canonical.Name refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID))
earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID) earliest, hasEvidence := order.EarliestValid(refs)
ordered[index] = orderedItemOccurrenceResponse{ ordered[index] = orderedItemOccurrence{
value: response.Occurrences[index], value: dnd.ItemOccurrence{
ItemID: item.ID,
Name: item.Name,
Kind: dnd.ItemOccurrenceKind(occurrence.Kind),
Quantity: cloneQuantity(occurrence.Quantity),
From: occurrence.From,
To: occurrence.To,
SourceRefs: refs,
},
earliest: earliest, earliest: earliest,
hasEvidence: hasEvidence, hasEvidence: hasEvidence,
} }
@@ -43,40 +51,9 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
} }
return ordered[left].earliest < ordered[right].earliest return ordered[left].earliest < ordered[right].earliest
}) })
for index := range ordered { occurrences := make([]dnd.ItemOccurrence, len(ordered))
response.Occurrences[index] = ordered[index].value for index, occurrence := range ordered {
} occurrences[index] = occurrence.value
return nil
}
func canonicalizeItemOccurrence(occurrence *itemOccurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
if occurrence == nil {
return 0, false
}
refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID))
occurrence.SourceRefs = itemOccurrenceResponseRefs(refs)
return order.EarliestValid(refs)
}
func canonicalItemOccurrenceList(response extractionResponse, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) {
if response.Occurrences == nil {
return dnd.ItemOccurrenceList{}, nil
}
occurrences := make([]dnd.ItemOccurrence, 0, len(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: item.ID,
Name: item.Name,
Kind: dnd.ItemOccurrenceKind(occurrence.Kind),
Quantity: cloneQuantity(occurrence.Quantity),
From: occurrence.From,
To: occurrence.To,
SourceRefs: itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID),
})
} }
return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil
} }
@@ -92,17 +69,6 @@ func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID s
return values return values
} }
func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse {
if refs == nil {
return nil
}
values := make([]itemOccurrenceSourceRefResponse, len(refs))
for index, ref := range refs {
values[index] = itemOccurrenceSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID}
}
return values
}
func cloneQuantity(value *int) *int { func cloneQuantity(value *int) *int {
if value == nil { if value == nil {
return nil return nil

View File

@@ -161,12 +161,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
}, &response); err != nil { }, &response); err != nil {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err) return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
} }
if err := canonicalizeResponse(&response, order, req.Source.ID, registry); err != nil { value, err := canonicalItemOccurrenceList(response, order, req.Source.ID, registry)
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err)
}
value, err := canonicalItemOccurrenceList(response, req.Source.ID, registry)
if err != nil { if err != nil {
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("resolve canonical item occurrence names: %w", err) return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err)
} }
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil
} }

View File

@@ -36,7 +36,6 @@ type ContextUnit struct {
// Its prompt input contains no durable IDs or source IDs. // Its prompt input contains no durable IDs or source IDs.
type Grounding struct { type Grounding struct {
promptInput contracts.LLMInputMaterial promptInput contracts.LLMInputMaterial
projectionDigest string
locationsBySelector map[string]dnd.Location locationsBySelector map[string]dnd.Location
} }
@@ -104,7 +103,6 @@ func NewGrounding(registry *Registry, doc *source.SourceDocument) (*Grounding, e
digest := semanticDigest(content) digest := semanticDigest(content)
return &Grounding{ return &Grounding{
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, digest, ""), promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, digest, ""),
projectionDigest: digest,
locationsBySelector: locationsBySelector, locationsBySelector: locationsBySelector,
}, nil }, nil
} }
@@ -118,14 +116,6 @@ func (g *Grounding) PromptInput() contracts.LLMInputMaterial {
return g.promptInput.Clone() return g.promptInput.Clone()
} }
// ProjectionDigest returns the digest of the exact contextual prompt input.
func (g *Grounding) ProjectionDigest() string {
if g == nil {
return ""
}
return g.projectionDigest
}
// Resolve maps a contextual selector to one canonical location. // Resolve maps a contextual selector to one canonical location.
func (g *Grounding) Resolve(selector Selector) (dnd.Location, bool) { func (g *Grounding) Resolve(selector Selector) (dnd.Location, bool) {
if g == nil { if g == nil {

View File

@@ -216,8 +216,8 @@ func TestGroundingProjectsAndResolvesUniqueAndSameNameLocations(t *testing.T) {
if bytes.Contains(grounding.PromptInput().Content, []byte("location:sha256:")) || bytes.Contains(grounding.PromptInput().Content, []byte(`"source_id"`)) { if bytes.Contains(grounding.PromptInput().Content, []byte("location:sha256:")) || bytes.Contains(grounding.PromptInput().Content, []byte(`"source_id"`)) {
t.Fatalf("grounding projection exposed durable identity: %s", grounding.PromptInput().Content) t.Fatalf("grounding projection exposed durable identity: %s", grounding.PromptInput().Content)
} }
if grounding.ProjectionDigest() == "" || grounding.ProjectionDigest() == registry.IdentityDigest() || grounding.PromptInput().Digest != grounding.ProjectionDigest() { if grounding.PromptInput().Digest == "" || grounding.PromptInput().Digest == registry.IdentityDigest() {
t.Fatalf("grounding/identity digests = %q/%q", grounding.ProjectionDigest(), registry.IdentityDigest()) t.Fatalf("grounding/identity digests = %q/%q", grounding.PromptInput().Digest, registry.IdentityDigest())
} }
resolved, ok := grounding.Resolve(Selector{Name: " the tavern ", RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}}) resolved, ok := grounding.Resolve(Selector{Name: " the tavern ", RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}})
@@ -276,8 +276,8 @@ func TestGroundingHandlesEmptyRegistriesAndIdentityFingerprints(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if base.IdentityDigest() != expanded.IdentityDigest() || baseGrounding.ProjectionDigest() != expandedGrounding.ProjectionDigest() { if base.IdentityDigest() != expanded.IdentityDigest() || baseGrounding.PromptInput().Digest != expandedGrounding.PromptInput().Digest {
t.Fatalf("identity/model fingerprints = %q/%q and %q/%q", base.IdentityDigest(), expanded.IdentityDigest(), baseGrounding.ProjectionDigest(), expandedGrounding.ProjectionDigest()) t.Fatalf("identity/model fingerprints = %q/%q and %q/%q", base.IdentityDigest(), expanded.IdentityDigest(), baseGrounding.PromptInput().Digest, expandedGrounding.PromptInput().Digest)
} }
} }