Use contextual descriptors for entity reconciliation

This commit is contained in:
2026-08-08 15:05:35 +00:00
parent 51d62de1f3
commit fc449863f2
18 changed files with 435 additions and 100 deletions

View File

@@ -14,9 +14,32 @@
"properties": {
"members": {
"type": "array",
"items": {"type": "string"}
"items": {"$ref": "#/$defs/selector"}
},
"canonical": {"type": "string"}
"canonical": {"$ref": "#/$defs/selector"}
}
}
}
},
"$defs": {
"selector": {
"type": "object",
"additionalProperties": false,
"required": ["name", "source_refs"],
"properties": {
"name": {"type": "string", "minLength": 1},
"source_refs": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["start_unit_id", "end_unit_id"],
"properties": {
"start_unit_id": {"type": "integer", "minimum": 1},
"end_unit_id": {"type": "integer", "minimum": 1}
}
}
}
}
}

View File

@@ -1,6 +1,7 @@
Identify only well-supported duplicate groups among the supplied candidates.
Candidate keys are opaque identifiers. Copy each selected key exactly. A group
must contain at least two supplied keys, and its `canonical` key must be one of
its members. Do not create keys, records, names, source references, evidence,
or replacement values. Omit any uncertain or unsafe group.
Return each selected candidate's supplied contextual descriptor exactly: its
`name` and complete ordered `source_refs`. A group must contain at least two
supplied descriptors, and its `canonical` descriptor must be one of its
members. Do not invent names, ranges, records, evidence, or replacement values.
Omit any uncertain or unsafe group.

View File

@@ -23,7 +23,7 @@ import (
const (
Key = "dnd/item-registry"
PromptID = "dnd.item_registry.normalize"
normalizationPolicy = "dnd.item_registry.normalize.v1"
normalizationPolicy = "dnd.item_registry.normalize.v2"
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
semanticContextRadius = 2
NormalizationPolicy = normalizationPolicy

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"reflect"
"strconv"
"strings"
"testing"
"time"
@@ -101,7 +102,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
t.Fatalf("merged item = %#v, warnings = %#v", merged, result.Warnings)
}
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) {
if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) {
t.Fatalf("private inputs = %s", encoded)
}
}
@@ -261,7 +262,7 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Rope","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil {
t.Fatal(err)
}
@@ -285,10 +286,51 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques
if response == "" {
response = `{"duplicate_groups":[]}`
}
if err := json.Unmarshal([]byte(response), output); err != nil {
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil
if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {

View File

@@ -23,7 +23,7 @@ import (
const (
Key = "dnd/location-registry"
PromptID = "dnd.location_registry.normalize"
normalizationPolicy = "dnd.location_registry.normalize.v1"
normalizationPolicy = "dnd.location_registry.normalize.v2"
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
semanticContextRadius = 2
NormalizationPolicy = normalizationPolicy

View File

@@ -60,7 +60,7 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t
}
}
func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) {
func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) {
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
doc := semanticDocument()
input := dnd.LocationRegistry{Locations: []dnd.Location{
@@ -77,7 +77,7 @@ func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) {
t.Fatalf("merged location = %#v, warnings = %#v", merged, result.Warnings)
}
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) {
if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) {
t.Fatalf("private inputs = %s", encoded)
}
}

View File

@@ -28,7 +28,7 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"The Tavern","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil {
t.Fatal(err)
}
@@ -40,14 +40,14 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
t.Fatalf("message %d cache = %#v", index, prepared.Messages[index].CacheControl)
}
}
if !strings.Contains(prepared.Messages[3].Content, "candidate-000001") || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
if !strings.Contains(prepared.Messages[3].Content, `"The Tavern"`) || strings.Contains(prepared.Messages[3].Content, `"candidate-`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
t.Fatalf("candidate message = %q", prepared.Messages[3].Content)
}
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, "candidate-000001") {
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"The Tavern"`) {
t.Fatalf("transcript message = %q", prepared.Messages[4].Content)
}
for index, message := range prepared.Messages {
if index != 3 && strings.Contains(message.Content, "candidate-000001") {
if index != 3 && strings.Contains(message.Content, `"The Tavern"`) {
t.Errorf("message %d unexpectedly rendered candidate input", index)
}
if index != 4 && strings.Contains(message.Content, `"windows"`) {

View File

@@ -3,11 +3,14 @@ package locationregistry
import (
"context"
"encoding/json"
"strconv"
"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"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
)
type recordingLocationNormalizerClient struct {
@@ -25,10 +28,51 @@ func (c *recordingLocationNormalizerClient) CompleteStructured(_ context.Context
if response == "" {
response = `{"duplicate_groups":[]}`
}
if err := json.Unmarshal([]byte(response), output); err != nil {
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil
if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {

View File

@@ -23,7 +23,7 @@ import (
const (
Key = "dnd/npc-registry"
PromptID = "dnd.npc_registry.normalize"
normalizationPolicy = "dnd.npc_registry.normalize.v3"
normalizationPolicy = "dnd.npc_registry.normalize.v4"
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
NormalizationPolicy = normalizationPolicy

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"reflect"
"strconv"
"strings"
"testing"
@@ -156,10 +157,51 @@ func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, req
if response == "" {
response = `{"duplicate_groups":[]}`
}
if err := json.Unmarshal([]byte(response), output); err != nil {
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil
if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {

View File

@@ -36,7 +36,7 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile",
Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Mira","source_refs":[]}]}`),
"candidates": promptkit.Inline(`{"candidates":[{"name":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
},
})

View File

@@ -2,6 +2,7 @@ package npcregistry
import (
"context"
"encoding/json"
"errors"
"math"
"reflect"
@@ -174,8 +175,8 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
}
}
func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`}
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","unknown"],"canonical":"candidate-000001"}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -187,8 +188,8 @@ func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
if err != nil || result.Retry == nil || len(result.Value.NPCs) != 3 {
t.Fatalf("Normalize() = %#v, %v; want unchanged retry fallback", result, err)
}
if !strings.Contains(result.Retry.Message, "member_ineligible") || result.Value.NPCs[1].Name != "Broken" {
t.Fatalf("result = %#v, want ineligible record excluded but preserved", result)
if !strings.Contains(result.Retry.Message, "member_unknown") || result.Value.NPCs[1].Name != "Broken" {
t.Fatalf("result = %#v, want unknown descriptor rejected without dropping a record", result)
}
}
@@ -206,8 +207,14 @@ func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) {
if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 {
t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content)
}
var candidateInput struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(materials.Candidates.Content, &candidateInput); err != nil {
t.Fatal(err)
}
assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{
Members: keys, Canonical: keys[1],
Members: candidateInput.Candidates, Canonical: candidateInput.Candidates[1],
}}})
groups := reconciliationGroups(assessment, keys)
if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {

View File

@@ -22,13 +22,34 @@ type Candidate struct {
SourceRefs []source.SourceRef
}
// Selector identifies one candidate through its canonical name and source-free
// evidence ranges. It is the complete model-facing candidate descriptor.
type Selector struct {
Name string `json:"name"`
SourceRefs []SourceRange `json:"source_refs"`
}
// Clone returns an owned copy of the selector.
func (s Selector) Clone() Selector {
s.SourceRefs = cloneSourceRanges(s.SourceRefs)
return s
}
// SourceRange is a source-free evidence coordinate used in a selector.
type SourceRange struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
}
// Materials contains owned prompt inputs and opaque candidate-key mappings.
type Materials struct {
Candidates contracts.LLMInputMaterial
Transcript contracts.LLMInputMaterial
candidateKeys []string
eligible map[string]struct{}
candidateKeys []string
eligible map[string]struct{}
keyBySelector map[string]string
collidedSelectors map[string]struct{}
}
// CandidateKeys returns all deterministic keys in candidate input order.
@@ -49,18 +70,7 @@ func (m Materials) EligibleCandidateKeys() []string {
}
type candidateInput struct {
Candidates []candidateView `json:"candidates"`
}
type candidateView struct {
Key string `json:"key"`
Name string `json:"name"`
SourceRefs []candidateSourceRef `json:"source_refs"`
}
type candidateSourceRef struct {
StartUnitID int `json:"start_unit_id"`
EndUnitID int `json:"end_unit_id"`
Candidates []Selector `json:"candidates"`
}
type transcriptInput struct {
@@ -91,8 +101,10 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
return Materials{}, false, fmt.Errorf("build entity reconciliation context: radius must not be negative")
}
materials := Materials{
candidateKeys: make([]string, len(candidates)),
eligible: make(map[string]struct{}),
candidateKeys: make([]string, len(candidates)),
eligible: make(map[string]struct{}),
keyBySelector: make(map[string]string),
collidedSelectors: make(map[string]struct{}),
}
for index := range candidates {
key := fmt.Sprintf(candidateKeyFormat, index+1)
@@ -103,7 +115,15 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
}
index := source.NewDocumentIndex(doc)
views := make([]candidateView, 0, len(candidates))
type preparedCandidate struct {
key string
selector Selector
intervals []sourceInterval
lookupKey string
}
prepared := make([]preparedCandidate, 0, len(candidates))
selectorCounts := make(map[string]int, len(candidates))
views := make([]Selector, 0, len(candidates))
intervals := make([]sourceInterval, 0)
cited := make([]bool, len(doc.Units))
for candidateIndex, candidate := range candidates {
@@ -112,9 +132,23 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
continue
}
key := materials.candidateKeys[candidateIndex]
materials.eligible[key] = struct{}{}
views = append(views, candidateView{Key: key, Name: candidate.Name, SourceRefs: references})
for _, interval := range candidateIntervals {
selector := Selector{Name: candidate.Name, SourceRefs: references}
lookupKey, err := selectorLookupKey(selector)
if err != nil {
return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid candidate material")
}
prepared = append(prepared, preparedCandidate{key: key, selector: selector, intervals: candidateIntervals, lookupKey: lookupKey})
selectorCounts[lookupKey]++
}
for _, candidate := range prepared {
if selectorCounts[candidate.lookupKey] != 1 {
materials.collidedSelectors[candidate.lookupKey] = struct{}{}
continue
}
materials.eligible[candidate.key] = struct{}{}
materials.keyBySelector[candidate.lookupKey] = candidate.key
views = append(views, candidate.selector.Clone())
for _, interval := range candidate.intervals {
for position := interval.start; position <= interval.end; position++ {
cited[position] = true
}
@@ -145,24 +179,56 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
return materials, true, nil
}
func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]candidateSourceRef, []sourceInterval, bool) {
func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]SourceRange, []sourceInterval, bool) {
if len(refs) == 0 {
return nil, nil, false
}
references := make([]candidateSourceRef, 0, len(refs))
intervals := make([]sourceInterval, 0, len(refs))
type referencedInterval struct {
reference SourceRange
interval sourceInterval
}
prepared := make([]referencedInterval, 0, len(refs))
for _, ref := range refs {
if err := index.ValidateRef(ref); err != nil {
return nil, nil, false
}
start, _ := index.Position(ref.StartUnitID)
end, _ := index.Position(ref.EndUnitID)
references = append(references, candidateSourceRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID})
intervals = append(intervals, sourceInterval{start: start, end: end})
prepared = append(prepared, referencedInterval{reference: SourceRange{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}, interval: sourceInterval{start: start, end: end}})
}
sort.Slice(prepared, func(left, right int) bool {
if prepared[left].interval.start != prepared[right].interval.start {
return prepared[left].interval.start < prepared[right].interval.start
}
return prepared[left].interval.end < prepared[right].interval.end
})
references := make([]SourceRange, 0, len(prepared))
intervals := make([]sourceInterval, 0, len(prepared))
for _, item := range prepared {
if len(references) > 0 && references[len(references)-1] == item.reference {
continue
}
references = append(references, item.reference)
intervals = append(intervals, item.interval)
}
return references, intervals, true
}
func selectorLookupKey(selector Selector) (string, error) {
content, err := json.Marshal(selector.Clone())
if err != nil {
return "", err
}
return string(content), nil
}
func cloneSourceRanges(values []SourceRange) []SourceRange {
if len(values) == 0 {
return []SourceRange{}
}
return append([]SourceRange(nil), values...)
}
func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
if len(intervals) == 0 {
return nil

View File

@@ -13,7 +13,7 @@ import (
"github.com/santhosh-tekuri/jsonschema/v6"
)
func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
func TestBuildContextUsesContextualSelectorsSourceOrderAndOwnedData(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
{ID: 40, Kind: "narration", Text: "zero"},
{ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}},
@@ -52,10 +52,10 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
if err := json.Unmarshal(materials.Candidates.Content, &candidatePayload); err != nil {
t.Fatal(err)
}
if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Key != "candidate-000001" || candidatePayload.Candidates[1].Key != "candidate-000002" || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name {
t.Fatalf("candidate payload = %#v, want distinct opaque keys for equal names", candidatePayload)
if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name || strings.Contains(string(materials.Candidates.Content), "candidate-") {
t.Fatalf("candidate payload = %#v, want contextual descriptors without keys", candidatePayload)
}
if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (candidateSourceRef{StartUnitID: 10, EndUnitID: 20}) {
if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (SourceRange{StartUnitID: 10, EndUnitID: 20}) {
t.Fatalf("candidate reference = %#v", got)
}
@@ -86,6 +86,50 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
}
}
func TestBuildContextExcludesCollidingDescriptors(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
candidates := []Candidate{
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
}
materials, ready, err := BuildContext(doc, candidates, 0)
if err != nil || ready || len(materials.EligibleCandidateKeys()) != 1 || strings.Contains(string(materials.Candidates.Content), "The Tavern") {
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
}
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
Members: []Selector{{Name: "The Tavern", SourceRefs: []SourceRange{{StartUnitID: 1, EndUnitID: 1}}}, {Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}}},
Canonical: Selector{Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}},
}}})
if !hasIssue(assessment.Issues(), "member_ineligible") {
t.Fatalf("Assess() issues = %#v, want collided descriptor rejection", assessment.Issues())
}
}
func TestAssessmentRejectsPartialAndReorderedDescriptors(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
materials, ready, err := BuildContext(doc, []Candidate{
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}, 0)
if err != nil || !ready {
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
}
selectors := materialSelectors(t, materials)
for _, refs := range [][]SourceRange{
{{StartUnitID: 10, EndUnitID: 10}},
{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}},
} {
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
Members: []Selector{{Name: "The Tavern", SourceRefs: refs}, selectors[1]},
Canonical: selectors[1],
}}})
if !hasIssue(assessment.Issues(), "member_unknown") {
t.Fatalf("Assess(%#v) issues = %#v, want descriptor mismatch rejection", refs, assessment.Issues())
}
}
}
func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *testing.T) {
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}}
candidates := []Candidate{
@@ -116,22 +160,21 @@ func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *test
func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
materials := preparedMaterials(t, 4, true)
keys := materials.CandidateKeys()
selectors := materialSelectors(t, materials)
unsafe := []struct {
name string
response ProposalResponse
category string
}{
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"", keys[1]}, Canonical: keys[1]}}}, "member_blank"},
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"candidate-999999", keys[1]}, Canonical: keys[1]}}}, "member_unknown"},
{"ineligible member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[3]}, Canonical: keys[0]}}}, "member_ineligible"},
{"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[0]}, Canonical: keys[0]}}}, "repeated_member"},
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0]}, Canonical: keys[0]}}}, "fewer_than_two_members"},
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: ""}}}, "canonical_blank"},
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: keys[2]}}}, "canonical_not_member"},
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{}, selectors[1]}, Canonical: selectors[1]}}}, "member_blank"},
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{Name: "unknown", SourceRefs: []SourceRange{{StartUnitID: 99, EndUnitID: 99}}}, selectors[1]}, Canonical: selectors[1]}}}, "member_unknown"},
{"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[0]}, Canonical: selectors[0]}}}, "repeated_member"},
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0]}, Canonical: selectors[0]}}}, "fewer_than_two_members"},
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: Selector{}}}}, "canonical_blank"},
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[2]}}}, "canonical_not_member"},
{"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{
{Members: []string{keys[0], keys[1]}, Canonical: keys[0]},
{Members: []string{keys[1], keys[2]}, Canonical: keys[2]},
{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[0]},
{Members: []Selector{selectors[1], selectors[2]}, Canonical: selectors[2]},
}}, "overlapping_member"},
}
for _, test := range unsafe {
@@ -147,9 +190,10 @@ func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) {
materials := preparedMaterials(t, 4, false)
keys := materials.CandidateKeys()
selectors := materialSelectors(t, materials)
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{Members: []string{keys[1], keys[0]}, Canonical: keys[1]},
{Members: []string{keys[3], keys[2]}, Canonical: keys[2]},
{Members: []Selector{selectors[1], selectors[0]}, Canonical: selectors[1]},
{Members: []Selector{selectors[3], selectors[2]}, Canonical: selectors[2]},
}})
groups := assessment.SafeGroups()
if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 {
@@ -183,12 +227,12 @@ func TestSharedResponseSchemaIsPrivateStrictAndRegisterableOnce(t *testing.T) {
valid bool
}{
{"empty groups", map[string]any{"duplicate_groups": []any{}}, true},
{"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{""}, "canonical": ""}}}, true},
{"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}}}, true},
{"missing groups", map[string]any{}, false},
{"unknown top level", map[string]any{"duplicate_groups": []any{}, "extra": true}, false},
{"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "name": "replacement"}}}, false},
{"replacement evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "source_refs": []any{}}}}, false},
{"wrong key type", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{1}, "canonical": "candidate-000001"}}}, false},
{"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}, "name": "replacement"}}}, false},
{"missing selector evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira"}}}}, false},
{"invalid range", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}}}}, false},
} {
t.Run(test.name, func(t *testing.T) {
content, err := json.Marshal(test.value)
@@ -236,6 +280,15 @@ func preparedMaterials(t *testing.T, count int, includeIneligible bool) Material
return materials
}
func materialSelectors(t *testing.T, materials Materials) []Selector {
t.Helper()
var input candidateInput
if err := json.Unmarshal(materials.Candidates.Content, &input); err != nil {
t.Fatal(err)
}
return input.Candidates
}
func cloneCandidates(input []Candidate) []Candidate {
output := make([]Candidate, len(input))
copy(output, input)

View File

@@ -6,15 +6,16 @@ import (
)
// ProposalResponse is the private structured response exchanged with the
// reconciliation prompt. It identifies candidates only by opaque keys.
// reconciliation prompt. It identifies candidates by contextual selectors.
type ProposalResponse struct {
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
}
// DuplicateGroup proposes candidate keys that might denote one entity.
// DuplicateGroup proposes contextual candidate descriptors that might denote
// one entity.
type DuplicateGroup struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
Members []Selector `json:"members"`
Canonical Selector `json:"canonical"`
}
// Issue identifies one unsafe proposal category without prescribing a warning
@@ -59,16 +60,13 @@ func (a Assessment) DiscardedGroups() int { return a.discardedGroups }
// Issues returns the deterministic rejection categories in proposal order.
func (a Assessment) Issues() []Issue { return append([]Issue(nil), a.issues...) }
// Assess validates a proposal against the opaque keys created by BuildContext.
// Assess resolves contextual descriptors to internal candidate keys, then
// validates the proposal without exposing those keys to the model.
func (m Materials) Assess(response ProposalResponse) Assessment {
all := make(map[string]struct{}, len(m.candidateKeys))
for _, key := range m.candidateKeys {
all[key] = struct{}{}
}
groups := make([]assessedGroup, len(response.DuplicateGroups))
issues := make([]Issue, 0)
for groupIndex, proposal := range response.DuplicateGroups {
groups[groupIndex] = assessGroup(proposal, all, m.eligible)
groups[groupIndex] = m.assessGroup(proposal)
for _, category := range groups[groupIndex].issues {
issues = append(issues, Issue{GroupIndex: groupIndex, Category: category})
}
@@ -115,12 +113,13 @@ type assessedGroup struct {
conflicting bool
}
func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) assessedGroup {
func (m Materials) assessGroup(proposal DuplicateGroup) assessedGroup {
issues := make([]string, 0)
members := make([]string, 0, len(proposal.Members))
seen := make(map[string]struct{}, len(proposal.Members))
for _, key := range proposal.Members {
if category := keyCategory(key, all, eligible); category != "" {
for _, selector := range proposal.Members {
key, category := m.selectorKey(selector)
if category != "" {
issues = append(issues, "member_"+category)
continue
}
@@ -131,31 +130,39 @@ func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) ass
seen[key] = struct{}{}
members = append(members, key)
}
canonicalCategory := keyCategory(proposal.Canonical, all, eligible)
canonical, canonicalCategory := m.selectorKey(proposal.Canonical)
if canonicalCategory != "" {
issues = append(issues, "canonical_"+canonicalCategory)
}
if len(members) < 2 {
issues = append(issues, "fewer_than_two_members")
}
if canonicalCategory == "" && !contains(members, proposal.Canonical) {
if canonicalCategory == "" && !contains(members, canonical) {
issues = append(issues, "canonical_not_member")
}
sort.Strings(members)
return assessedGroup{members: members, canonical: proposal.Canonical, issues: issues, locallyValid: len(issues) == 0}
return assessedGroup{members: members, canonical: canonical, issues: issues, locallyValid: len(issues) == 0}
}
func keyCategory(key string, all, eligible map[string]struct{}) string {
if strings.TrimSpace(key) == "" {
return "blank"
func (m Materials) selectorKey(selector Selector) (string, string) {
if strings.TrimSpace(selector.Name) == "" {
return "", "blank"
}
if _, ok := all[key]; !ok {
return "unknown"
lookupKey, err := selectorLookupKey(selector)
if err != nil {
return "", "unknown"
}
if _, ok := eligible[key]; !ok {
return "ineligible"
if _, collided := m.collidedSelectors[lookupKey]; collided {
return "", "ineligible"
}
return ""
key, ok := m.keyBySelector[lookupKey]
if !ok {
return "", "unknown"
}
if _, eligible := m.eligible[key]; !eligible {
return "", "ineligible"
}
return key, ""
}
func contains(values []string, want string) bool {

View File

@@ -51,7 +51,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
for name, value := range map[string]string{
"extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2",
"normalize:npc_registry:dnd/npc-registry:identity_policy": "dnd.npc_registry.identity.v1",
"normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v3",
"normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v4",
"normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2",
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",
"extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1",

View File

@@ -292,7 +292,14 @@ func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context,
}
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
case npcnormalize.PromptID:
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}}
content, err := contextualReconciliationContent([]byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`), request.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, err
}
return contracts.StructuredCompletionResponse{Content: content}, nil
case occurrenceextract.PromptID:
payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
default:

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
@@ -266,6 +267,11 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
}
content = append([]byte(nil), client.normalizeResponses[index]...)
}
var err error
content, err = contextualReconciliationContent(content, req.Inputs["candidates"].Content)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
default:
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID)
}
@@ -275,6 +281,43 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func contextualReconciliationContent(content, candidateContent []byte) ([]byte, error) {
if !strings.Contains(string(content), "candidate-") {
return content, nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal(content, &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int {
count := 0
for _, request := range client.requests {