Use contextual descriptors for entity reconciliation
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user