Files
notarius/internal/framework/semanticreconcile/proposal.go

237 lines
7.3 KiB
Go

package semanticreconcile
import (
"fmt"
"sort"
)
// ProposalResponse is the complete private structured response contract.
type ProposalResponse struct {
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
}
// DuplicateGroup proposes supplied request-local candidate IDs that may denote
// one entity and identifies one supplied member as canonical.
type DuplicateGroup struct {
CandidateIDs []int `json:"candidate_ids"`
CanonicalCandidateID int `json:"canonical_candidate_id"`
}
// IssueCategory identifies one stable proposal safety failure.
type IssueCategory string
const (
IssueMemberNonPositive IssueCategory = "member_non_positive"
IssueMemberUnknown IssueCategory = "member_unknown"
IssueRepeatedMember IssueCategory = "repeated_member"
IssueFewerThanTwoMembers IssueCategory = "fewer_than_two_members"
IssueCanonicalNonPositive IssueCategory = "canonical_non_positive"
IssueCanonicalUnknown IssueCategory = "canonical_unknown"
IssueCanonicalNotMember IssueCategory = "canonical_not_member"
IssueOverlappingMember IssueCategory = "overlapping_member"
)
// Issue identifies an unsafe proposal category at its original response group
// index without prescribing caller warning text.
type Issue struct {
GroupIndex int
Category IssueCategory
}
// IssueDetails renders stable, domain-neutral proposal diagnostics for an
// adapter's retry message.
func IssueDetails(issues []Issue) []string {
details := make([]string, len(issues))
for index, issue := range issues {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
}
return details
}
// PlanGroup identifies one validated group using original candidate positions.
type PlanGroup struct {
memberPositions []int
canonicalPosition int
}
// MemberPositions returns an owned, ascending list of original candidate
// positions.
func (group PlanGroup) MemberPositions() []int {
return append([]int(nil), group.memberPositions...)
}
// CanonicalPosition returns the original position of the selected canonical
// candidate.
func (group PlanGroup) CanonicalPosition() int {
return group.canonicalPosition
}
// Plan contains deterministic, non-overlapping reconciliation groups.
type Plan struct {
groups []PlanGroup
}
// Groups returns a deeply owned copy ordered by each group's earliest member.
func (plan Plan) Groups() []PlanGroup {
groups := make([]PlanGroup, len(plan.groups))
for index, group := range plan.groups {
groups[index] = clonePlanGroup(group)
}
return groups
}
// Assessment contains the safe plan and stable diagnostics for discarded
// response groups.
type Assessment struct {
plan Plan
discardedGroupCount int
issues []Issue
}
// Plan returns an independently owned reconciliation plan.
func (assessment Assessment) Plan() Plan {
groups := assessment.plan.Groups()
return Plan{groups: groups}
}
// Issues returns an owned copy ordered by original response group index.
func (assessment Assessment) Issues() []Issue {
return append([]Issue(nil), assessment.issues...)
}
// DiscardedGroupCount returns the number of response groups excluded from the
// safe plan.
func (assessment Assessment) DiscardedGroupCount() int {
return assessment.discardedGroupCount
}
// RetryRequired reports whether any response group was discarded.
func (assessment Assessment) RetryRequired() bool {
return assessment.discardedGroupCount > 0
}
type assessedGroup struct {
memberPositions []int
canonicalPosition int
issues []IssueCategory
locallyValid bool
conflicting bool
}
// Assess resolves request-local IDs through the retained preparation mapping
// and returns only deterministic, non-overlapping groups.
func (preparation Preparation) Assess(response ProposalResponse) Assessment {
positionsByID := make(map[int]int, len(preparation.mappings))
for _, mapping := range preparation.mappings {
positionsByID[mapping.CandidateID] = mapping.CandidatePosition
}
groups := make([]assessedGroup, len(response.DuplicateGroups))
owners := make(map[int][]int)
for groupIndex, proposal := range response.DuplicateGroups {
groups[groupIndex] = assessGroup(proposal, positionsByID)
if !groups[groupIndex].locallyValid {
continue
}
for _, position := range groups[groupIndex].memberPositions {
owners[position] = append(owners[position], groupIndex)
}
}
for _, groupIndexes := range owners {
if len(groupIndexes) < 2 {
continue
}
for _, groupIndex := range groupIndexes {
groups[groupIndex].conflicting = true
}
}
assessment := Assessment{}
for groupIndex, group := range groups {
for _, category := range group.issues {
assessment.issues = append(assessment.issues, Issue{GroupIndex: groupIndex, Category: category})
}
if group.conflicting {
assessment.issues = append(assessment.issues, Issue{GroupIndex: groupIndex, Category: IssueOverlappingMember})
}
if !group.locallyValid || group.conflicting {
assessment.discardedGroupCount++
continue
}
assessment.plan.groups = append(assessment.plan.groups, PlanGroup{
memberPositions: append([]int(nil), group.memberPositions...),
canonicalPosition: group.canonicalPosition,
})
}
sort.Slice(assessment.plan.groups, func(left, right int) bool {
return assessment.plan.groups[left].memberPositions[0] < assessment.plan.groups[right].memberPositions[0]
})
return assessment
}
func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGroup {
group := assessedGroup{}
seenIDs := make(map[int]struct{}, len(proposal.CandidateIDs))
memberPositions := make(map[int]struct{}, len(proposal.CandidateIDs))
for _, candidateID := range proposal.CandidateIDs {
if _, repeated := seenIDs[candidateID]; repeated {
group.issues = append(group.issues, IssueRepeatedMember)
continue
}
seenIDs[candidateID] = struct{}{}
position, category := resolveMember(candidateID, positionsByID)
if category != "" {
group.issues = append(group.issues, category)
continue
}
memberPositions[position] = struct{}{}
group.memberPositions = append(group.memberPositions, position)
}
if len(memberPositions) < 2 {
group.issues = append(group.issues, IssueFewerThanTwoMembers)
}
canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateID, positionsByID)
if canonicalCategory != "" {
group.issues = append(group.issues, canonicalCategory)
} else {
group.canonicalPosition = canonicalPosition
if _, member := memberPositions[canonicalPosition]; !member {
group.issues = append(group.issues, IssueCanonicalNotMember)
}
}
sort.Ints(group.memberPositions)
group.locallyValid = len(group.issues) == 0
return group
}
func resolveMember(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
if candidateID <= 0 {
return 0, IssueMemberNonPositive
}
position, exists := positionsByID[candidateID]
if !exists {
return 0, IssueMemberUnknown
}
return position, ""
}
func resolveCanonical(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
if candidateID <= 0 {
return 0, IssueCanonicalNonPositive
}
position, exists := positionsByID[candidateID]
if !exists {
return 0, IssueCanonicalUnknown
}
return position, ""
}
func clonePlanGroup(group PlanGroup) PlanGroup {
return PlanGroup{
memberPositions: append([]int(nil), group.memberPositions...),
canonicalPosition: group.canonicalPosition,
}
}