Files
notarius/internal/modules/dnd/shared/entityreconcile/proposal.go

169 lines
4.9 KiB
Go

package entityreconcile
import (
"sort"
"strings"
)
// ProposalResponse is the private structured response exchanged with the
// reconciliation prompt. It identifies candidates only by opaque keys.
type ProposalResponse struct {
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
}
// DuplicateGroup proposes candidate keys that might denote one entity.
type DuplicateGroup struct {
Members []string `json:"members"`
Canonical string `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 validates a proposal against the opaque keys created by BuildContext.
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)
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 assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) 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 != "" {
issues = append(issues, "member_"+category)
continue
}
if _, exists := seen[key]; exists {
issues = append(issues, "repeated_member")
continue
}
seen[key] = struct{}{}
members = append(members, key)
}
canonicalCategory := keyCategory(proposal.Canonical, all, eligible)
if canonicalCategory != "" {
issues = append(issues, "canonical_"+canonicalCategory)
}
if len(members) < 2 {
issues = append(issues, "fewer_than_two_members")
}
if canonicalCategory == "" && !contains(members, proposal.Canonical) {
issues = append(issues, "canonical_not_member")
}
sort.Strings(members)
return assessedGroup{members: members, canonical: proposal.Canonical, issues: issues, locallyValid: len(issues) == 0}
}
func keyCategory(key string, all, eligible map[string]struct{}) string {
if strings.TrimSpace(key) == "" {
return "blank"
}
if _, ok := all[key]; !ok {
return "unknown"
}
if _, ok := eligible[key]; !ok {
return "ineligible"
}
return ""
}
func contains(values []string, want string) bool {
for _, value := range values {
if value == want {
return true
}
}
return false
}