Clarify semantic reconciliation candidate numbers

This commit is contained in:
2026-08-29 03:16:24 +00:00
parent 917d150279
commit 7e626753bf
30 changed files with 572 additions and 196 deletions

View File

@@ -14,11 +14,11 @@ 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.
// DuplicateGroup proposes supplied request-local candidate numbers 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"`
CandidateNumbers []int `json:"candidate_numbers"`
CanonicalCandidateNumber int `json:"canonical_candidate_number"`
}
// IssueCategory identifies one stable proposal safety failure.
@@ -47,14 +47,14 @@ var allIssueCategories = []IssueCategory{
}
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.",
IssueMemberNonPositive: "Use only candidate numbers in %s.",
IssueMemberUnknown: "Remove every candidate number outside %s.",
IssueRepeatedMember: "List each candidate number at most once within the duplicate group.",
IssueFewerThanTwoMembers: "Include at least two distinct candidate numbers, or omit the duplicate group.",
IssueCanonicalNonPositive: "Choose `canonical_candidate_number` from %s.",
IssueCanonicalUnknown: "Choose `canonical_candidate_number` from %s.",
IssueCanonicalNotMember: "Make `canonical_candidate_number` one of the `candidate_numbers` in the same duplicate group.",
IssueOverlappingMember: "Place each candidate number in at most one duplicate group.",
}
// Issue identifies an unsafe proposal category at its original response group
@@ -77,7 +77,10 @@ func IssueDetails(issues []Issue) []string {
// 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) {
func CorrectionDetails(issues []Issue, numberRange CandidateNumberRange) ([]string, error) {
if err := validateCorrectionRange(numberRange); err != nil {
return nil, err
}
groupsByCategory := make(map[IssueCategory][]int)
seen := make(map[IssueCategory]map[int]struct{})
for _, issue := range issues {
@@ -98,13 +101,22 @@ func CorrectionDetails(issues []Issue) ([]string, error) {
}
details := make([]string, 0, len(groupsByCategory))
includeNamespaceReminder := false
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]))
prose := issueCorrectionProse[category]
if issueNeedsCandidateRange(category) {
prose = fmt.Sprintf(prose, candidateRangeDescription(numberRange))
includeNamespaceReminder = true
}
details = append(details, fmt.Sprintf("%s: %s", correctionGroupLabel(groups), prose))
}
if includeNamespaceReminder {
details = append(details, "Candidate numbers are the request-local `candidate_number` values in that range. Transcript unit `id` values and evidence `start_unit_id` and `end_unit_id` values are source positions, not candidate numbers.")
}
return details, nil
}
@@ -112,8 +124,8 @@ func CorrectionDetails(issues []Issue) ([]string, error) {
// 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)
func CorrectionGuidance(issues []Issue, numberRange CandidateNumberRange, additionalDetails ...string) (string, error) {
details, err := CorrectionDetails(issues, numberRange)
if err != nil {
return "", err
}
@@ -142,6 +154,26 @@ func CorrectionGuidance(issues []Issue, additionalDetails ...string) (string, er
return guidance, nil
}
func validateCorrectionRange(numberRange CandidateNumberRange) error {
if numberRange.First != 1 || numberRange.Last < 2 {
return fmt.Errorf("semantic reconciliation correction candidate-number range must start at 1 and include at least two candidates")
}
return nil
}
func candidateRangeDescription(numberRange CandidateNumberRange) string {
return fmt.Sprintf("the inclusive candidate-number range %d through %d", numberRange.First, numberRange.Last)
}
func issueNeedsCandidateRange(category IssueCategory) bool {
switch category {
case IssueMemberNonPositive, IssueMemberUnknown, IssueCanonicalNonPositive, IssueCanonicalUnknown:
return true
default:
return false
}
}
func correctionGroupLabel(groupIndexes []int) string {
const maximumDisplayedGroups = 12
displayed := groupIndexes
@@ -232,18 +264,18 @@ type assessedGroup struct {
conflicting bool
}
// Assess resolves request-local IDs through the retained preparation mapping
// and returns only deterministic, non-overlapping groups.
// Assess resolves request-local candidate numbers 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))
positionsByNumber := make(map[int]int, len(preparation.mappings))
for _, mapping := range preparation.mappings {
positionsByID[mapping.CandidateID] = mapping.CandidatePosition
positionsByNumber[mapping.CandidateNumber] = mapping.CandidatePosition
}
groups := make([]assessedGroup, len(response.DuplicateGroups))
owners := make(map[int][]int)
for groupIndex, proposal := range response.DuplicateGroups {
groups[groupIndex] = assessGroup(proposal, positionsByID)
groups[groupIndex] = assessGroup(proposal, positionsByNumber)
if !groups[groupIndex].locallyValid {
continue
}
@@ -283,17 +315,17 @@ func (preparation Preparation) Assess(response ProposalResponse) Assessment {
return assessment
}
func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGroup {
func assessGroup(proposal DuplicateGroup, positionsByNumber 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 {
seenNumbers := make(map[int]struct{}, len(proposal.CandidateNumbers))
memberPositions := make(map[int]struct{}, len(proposal.CandidateNumbers))
for _, candidateNumber := range proposal.CandidateNumbers {
if _, repeated := seenNumbers[candidateNumber]; repeated {
group.issues = append(group.issues, IssueRepeatedMember)
continue
}
seenIDs[candidateID] = struct{}{}
position, category := resolveMember(candidateID, positionsByID)
seenNumbers[candidateNumber] = struct{}{}
position, category := resolveMember(candidateNumber, positionsByNumber)
if category != "" {
group.issues = append(group.issues, category)
continue
@@ -305,7 +337,7 @@ func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGro
group.issues = append(group.issues, IssueFewerThanTwoMembers)
}
canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateID, positionsByID)
canonicalPosition, canonicalCategory := resolveCanonical(proposal.CanonicalCandidateNumber, positionsByNumber)
if canonicalCategory != "" {
group.issues = append(group.issues, canonicalCategory)
} else {
@@ -320,22 +352,22 @@ func assessGroup(proposal DuplicateGroup, positionsByID map[int]int) assessedGro
return group
}
func resolveMember(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
if candidateID <= 0 {
func resolveMember(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) {
if candidateNumber <= 0 {
return 0, IssueMemberNonPositive
}
position, exists := positionsByID[candidateID]
position, exists := positionsByNumber[candidateNumber]
if !exists {
return 0, IssueMemberUnknown
}
return position, ""
}
func resolveCanonical(candidateID int, positionsByID map[int]int) (int, IssueCategory) {
if candidateID <= 0 {
func resolveCanonical(candidateNumber int, positionsByNumber map[int]int) (int, IssueCategory) {
if candidateNumber <= 0 {
return 0, IssueCanonicalNonPositive
}
position, exists := positionsByID[candidateID]
position, exists := positionsByNumber[candidateNumber]
if !exists {
return 0, IssueCanonicalUnknown
}