Improve semantic reconciliation retries

This commit is contained in:
2026-08-28 18:25:00 +00:00
parent 5cab4e512e
commit 4da9360d74
25 changed files with 771 additions and 69 deletions

View File

@@ -13,7 +13,7 @@ import (
)
// Policy identifies the framework-owned reconciliation and assessment rules.
const Policy = "semantic_reconciliation.v1"
const Policy = "semantic_reconciliation.v2"
var _ contracts.ManifestMetadataProvider = (*Engine)(nil)
var _ pipeline.CheckpointFingerprintProvider = (*Engine)(nil)

View File

@@ -3,6 +3,10 @@ package semanticreconcile
import (
"fmt"
"sort"
"strings"
"unicode/utf8"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// ProposalResponse is the complete private structured response contract.
@@ -31,6 +35,28 @@ const (
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 {
@@ -38,8 +64,8 @@ type Issue struct {
Category IssueCategory
}
// IssueDetails renders stable, domain-neutral proposal diagnostics for an
// adapter's retry message.
// 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 {
@@ -48,6 +74,94 @@ func IssueDetails(issues []Issue) []string {
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

View File

@@ -2,9 +2,11 @@ package semanticreconcile
import (
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) {
@@ -50,6 +52,72 @@ func TestIssueDetailsPreservesIssueOrder(t *testing.T) {
}
}
func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels(t *testing.T) {
if len(issueCorrectionProse) != len(allIssueCategories) {
t.Fatalf("correction prose entries = %d, categories = %d", len(issueCorrectionProse), len(allIssueCategories))
}
issues := make([]Issue, len(allIssueCategories))
seen := make(map[IssueCategory]struct{}, len(allIssueCategories))
for index, category := range allIssueCategories {
if _, duplicate := seen[category]; duplicate {
t.Fatalf("duplicate authoritative issue category %q", category)
}
seen[category] = struct{}{}
if strings.TrimSpace(issueCorrectionProse[category]) == "" {
t.Fatalf("issue category %q has no model-facing prose", category)
}
issues[index] = Issue{GroupIndex: index, Category: category}
}
details, err := CorrectionDetails(issues)
if err != nil {
t.Fatalf("CorrectionDetails() error = %v", err)
}
guidance, err := CorrectionGuidance(issues)
if err != nil {
t.Fatalf("CorrectionGuidance() error = %v", err)
}
if len(details) != len(allIssueCategories) || !strings.Contains(guidance, "Duplicate group 1") || !strings.Contains(guidance, "complete corrected JSON response") || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes {
t.Fatalf("correction details = %#v guidance = %q", details, guidance)
}
for _, category := range allIssueCategories {
if strings.Contains(guidance, string(category)) {
t.Fatalf("model guidance exposed internal category %q: %q", category, guidance)
}
}
}
func TestCorrectionDetailsDeduplicatesAndBoundsAffectedGroupLists(t *testing.T) {
issues := make([]Issue, 0, 257)
for group := 0; group < 256; group++ {
issues = append(issues, Issue{GroupIndex: group, Category: IssueMemberUnknown})
}
issues = append(issues, Issue{GroupIndex: 0, Category: IssueMemberUnknown})
details, err := CorrectionDetails(issues)
if err != nil {
t.Fatalf("CorrectionDetails() error = %v", err)
}
if len(details) != 1 || !strings.Contains(details[0], "additional affected group") || strings.Count(details[0], "Duplicate groups") != 1 {
t.Fatalf("CorrectionDetails() = %#v, want one bounded grouped detail", details)
}
guidance, err := CorrectionGuidance(issues)
if err != nil || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes {
t.Fatalf("CorrectionGuidance() = %q, %v", guidance, err)
}
}
func TestCorrectionDetailsRejectsUnknownOrInvalidIssues(t *testing.T) {
for _, issues := range [][]Issue{
{{GroupIndex: 0, Category: "future_unmapped_category"}},
{{GroupIndex: -1, Category: IssueMemberUnknown}},
} {
if details, err := CorrectionDetails(issues); err == nil || details != nil {
t.Fatalf("CorrectionDetails(%#v) = %#v, %v; want fail-closed error", issues, details, err)
}
}
}
func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
preparation := proposalPreparation(t)
tests := []struct {