package semanticreconcile import ( "fmt" "sort" "strings" "unicode/utf8" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) // 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" ) var allIssueCategories = []IssueCategory{ IssueMemberNonPositive, IssueMemberUnknown, IssueRepeatedMember, IssueFewerThanTwoMembers, IssueCanonicalNonPositive, IssueCanonicalUnknown, IssueCanonicalNotMember, IssueOverlappingMember, } var issueCorrectionProse = map[IssueCategory]string{ IssueMemberNonPositive: "Use only positive candidate IDs from the supplied candidate list.", IssueMemberUnknown: "Remove every candidate ID that is not present in the supplied candidate list.", IssueRepeatedMember: "List each candidate ID at most once within the duplicate group.", IssueFewerThanTwoMembers: "Include at least two distinct candidate IDs, or omit the duplicate group.", IssueCanonicalNonPositive: "Choose a positive canonical_candidate_id from the supplied candidate list.", IssueCanonicalUnknown: "Choose canonical_candidate_id from the supplied candidate list.", IssueCanonicalNotMember: "Make canonical_candidate_id one of the candidate_ids in the same duplicate group.", IssueOverlappingMember: "Place each candidate ID in at most one duplicate group.", } // Issue identifies an unsafe proposal category at its original response group // index without prescribing caller diagnostic text. type Issue struct { GroupIndex int Category IssueCategory } // IssueDetails renders stable, domain-neutral proposal diagnostics for // operators and debug records. Its internal categories are not model guidance. 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 } // CorrectionDetails translates proposal issues into stable model-facing prose. // The response-local group ordinals help the model find the defective group in // the exact response appended to the correction request. func CorrectionDetails(issues []Issue) ([]string, error) { groupsByCategory := make(map[IssueCategory][]int) seen := make(map[IssueCategory]map[int]struct{}) for _, issue := range issues { if issue.GroupIndex < 0 { return nil, fmt.Errorf("semantic reconciliation issue group index must not be negative") } if _, exists := issueCorrectionProse[issue.Category]; !exists { return nil, fmt.Errorf("semantic reconciliation issue category %q has no correction guidance", issue.Category) } if seen[issue.Category] == nil { seen[issue.Category] = make(map[int]struct{}) } if _, exists := seen[issue.Category][issue.GroupIndex]; exists { continue } seen[issue.Category][issue.GroupIndex] = struct{}{} groupsByCategory[issue.Category] = append(groupsByCategory[issue.Category], issue.GroupIndex) } details := make([]string, 0, len(groupsByCategory)) for _, category := range allIssueCategories { groups := groupsByCategory[category] if len(groups) == 0 { continue } sort.Ints(groups) details = append(details, fmt.Sprintf("%s: %s", correctionGroupLabel(groups), issueCorrectionProse[category])) } return details, nil } // CorrectionGuidance builds one bounded request for a complete corrected // proposal. Additional details let a typed owner append a domain rule without // weakening or duplicating the shared protocol guidance. func CorrectionGuidance(issues []Issue, additionalDetails ...string) (string, error) { details, err := CorrectionDetails(issues) if err != nil { return "", err } for _, detail := range additionalDetails { if !utf8.ValidString(detail) { return "", fmt.Errorf("semantic reconciliation additional correction detail has invalid UTF-8") } detail = strings.TrimSpace(detail) if detail == "" { return "", fmt.Errorf("semantic reconciliation additional correction detail must not be blank") } details = append(details, detail) } if len(details) == 0 { return "", fmt.Errorf("semantic reconciliation correction guidance requires at least one detail") } parts := make([]string, 0, len(details)+2) parts = append(parts, "The previous semantic-duplicate proposal was invalid.") parts = append(parts, details...) parts = append(parts, "Return one complete corrected JSON response that follows the original instructions; do not return a patch or commentary.") guidance := strings.Join(parts, " ") if len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes { return "", fmt.Errorf("semantic reconciliation correction guidance exceeds maximum length") } return guidance, nil } func correctionGroupLabel(groupIndexes []int) string { const maximumDisplayedGroups = 12 displayed := groupIndexes if len(displayed) > maximumDisplayedGroups { displayed = displayed[:maximumDisplayedGroups] } ordinals := make([]string, len(displayed)) for index, groupIndex := range displayed { ordinals[index] = fmt.Sprintf("%d", groupIndex+1) } if len(groupIndexes) == 1 { return "Duplicate group " + ordinals[0] } label := "Duplicate groups " + strings.Join(ordinals, ", ") if omitted := len(groupIndexes) - len(displayed); omitted > 0 { label += fmt.Sprintf(", and %d additional affected group(s)", omitted) } return label } // 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, } }