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

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