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 }