Add semantic reconciliation proposal validation
This commit is contained in:
223
internal/framework/semanticreconcile/proposal.go
Normal file
223
internal/framework/semanticreconcile/proposal.go
Normal file
@@ -0,0 +1,223 @@
|
||||
package semanticreconcile
|
||||
|
||||
import "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
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user