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 }