176 lines
5.1 KiB
Go
176 lines
5.1 KiB
Go
package entityreconcile
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// ProposalResponse is the private structured response exchanged with the
|
|
// reconciliation prompt. It identifies candidates by contextual selectors.
|
|
type ProposalResponse struct {
|
|
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
|
|
}
|
|
|
|
// DuplicateGroup proposes contextual candidate descriptors that might denote
|
|
// one entity.
|
|
type DuplicateGroup struct {
|
|
Members []Selector `json:"members"`
|
|
Canonical Selector `json:"canonical"`
|
|
}
|
|
|
|
// Issue identifies one unsafe proposal category without prescribing a warning
|
|
// message or retry policy to a consuming normalizer.
|
|
type Issue struct {
|
|
GroupIndex int
|
|
Category string
|
|
}
|
|
|
|
// SafeGroup identifies one validated, non-overlapping duplicate group.
|
|
type SafeGroup struct {
|
|
members []string
|
|
canonical string
|
|
}
|
|
|
|
// Members returns an owned copy of the group's candidate keys.
|
|
func (g SafeGroup) Members() []string { return append([]string(nil), g.members...) }
|
|
|
|
// Canonical returns the selected canonical candidate key.
|
|
func (g SafeGroup) Canonical() string { return g.canonical }
|
|
|
|
// Assessment contains only safe groups. Its accessors return owned copies so
|
|
// callers cannot mutate retained assessment data.
|
|
type Assessment struct {
|
|
safeGroups []SafeGroup
|
|
discardedGroups int
|
|
issues []Issue
|
|
}
|
|
|
|
// SafeGroups returns validated non-overlapping groups in proposal order.
|
|
func (a Assessment) SafeGroups() []SafeGroup {
|
|
groups := make([]SafeGroup, len(a.safeGroups))
|
|
for index, group := range a.safeGroups {
|
|
groups[index] = SafeGroup{members: append([]string(nil), group.members...), canonical: group.canonical}
|
|
}
|
|
return groups
|
|
}
|
|
|
|
// DiscardedGroups returns the number of rejected proposal groups.
|
|
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 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 {
|
|
groups := make([]assessedGroup, len(response.DuplicateGroups))
|
|
issues := make([]Issue, 0)
|
|
for groupIndex, proposal := range response.DuplicateGroups {
|
|
groups[groupIndex] = m.assessGroup(proposal)
|
|
for _, category := range groups[groupIndex].issues {
|
|
issues = append(issues, Issue{GroupIndex: groupIndex, Category: category})
|
|
}
|
|
}
|
|
|
|
owners := make(map[string][]int)
|
|
for groupIndex, group := range groups {
|
|
if !group.locallyValid {
|
|
continue
|
|
}
|
|
for _, key := range group.members {
|
|
owners[key] = append(owners[key], groupIndex)
|
|
}
|
|
}
|
|
for groupIndex := range groups {
|
|
if !groups[groupIndex].locallyValid {
|
|
continue
|
|
}
|
|
for _, key := range groups[groupIndex].members {
|
|
if len(owners[key]) > 1 {
|
|
groups[groupIndex].conflicting = true
|
|
issues = append(issues, Issue{GroupIndex: groupIndex, Category: "overlapping_member"})
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
assessment := Assessment{issues: issues}
|
|
for _, group := range groups {
|
|
if !group.locallyValid || group.conflicting {
|
|
assessment.discardedGroups++
|
|
continue
|
|
}
|
|
assessment.safeGroups = append(assessment.safeGroups, SafeGroup{members: append([]string(nil), group.members...), canonical: group.canonical})
|
|
}
|
|
return assessment
|
|
}
|
|
|
|
type assessedGroup struct {
|
|
members []string
|
|
canonical string
|
|
issues []string
|
|
locallyValid bool
|
|
conflicting bool
|
|
}
|
|
|
|
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 _, selector := range proposal.Members {
|
|
key, category := m.selectorKey(selector)
|
|
if category != "" {
|
|
issues = append(issues, "member_"+category)
|
|
continue
|
|
}
|
|
if _, exists := seen[key]; exists {
|
|
issues = append(issues, "repeated_member")
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
members = append(members, key)
|
|
}
|
|
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, canonical) {
|
|
issues = append(issues, "canonical_not_member")
|
|
}
|
|
sort.Strings(members)
|
|
return assessedGroup{members: members, canonical: canonical, issues: issues, locallyValid: len(issues) == 0}
|
|
}
|
|
|
|
func (m Materials) selectorKey(selector Selector) (string, string) {
|
|
if strings.TrimSpace(selector.Name) == "" {
|
|
return "", "blank"
|
|
}
|
|
lookupKey, err := selectorLookupKey(selector)
|
|
if err != nil {
|
|
return "", "unknown"
|
|
}
|
|
if _, collided := m.collidedSelectors[lookupKey]; collided {
|
|
return "", "ineligible"
|
|
}
|
|
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 {
|
|
for _, value := range values {
|
|
if value == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|