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

@@ -239,8 +239,8 @@ func TestRunProducerAttemptsUsesModuleRetryBudgetAndFallback(t *testing.T) {
t.Run("feedback retry", func(t *testing.T) {
const (
defective = `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`
guidance = "Use only candidate IDs from the supplied candidate list. Return one complete corrected response."
defective = `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`
guidance = "Use only candidate numbers from the supplied candidate list. Return one complete corrected response."
)
var observed *contracts.SemanticCorrection
terminal, err := runProducerAttempts(context.Background(), producerAttemptConfig{Retries: 1, Policy: DefaultValidationPolicy()}, func(_ context.Context, request producerAttemptRequest) (producerAttemptOutput, error) {

View File

@@ -155,8 +155,8 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) {
func TestRunnerForwardsModuleRequestedNormalizeCorrection(t *testing.T) {
const (
defective = `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`
guidance = "Duplicate group 1 must use only supplied candidate IDs. Return one complete corrected response."
defective = `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`
guidance = "Duplicate group 1 must use only supplied candidate numbers. Return one complete corrected response."
)
prepared := preparedAttemptDebugPipeline(t)
lane := &prepared.Steps[0].lanes[0]

View File

@@ -31,7 +31,7 @@ func TestRegisterAssetsPreparesGenericPromptOffline(t *testing.T) {
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "semantic-reconciliation-test",
Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Mira"},{"candidate_id":2,"label":"Captain Mira"}]}`),
"candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":2},"candidates":[{"candidate_number":1,"label":"Mira"},{"candidate_number":2,"label":"Captain Mira"}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[{"unit_id":7,"text":"Mira arrived."}]}]}`),
},
})
@@ -60,15 +60,15 @@ func TestRegisterAssetsPreparesGenericPromptOffline(t *testing.T) {
}
}
protocol := prepared.Messages[1].Content
for _, requirement := range []string{"positive integer", "Return IDs only", "do not copy candidate names", "source ranges"} {
for _, requirement := range []string{"candidate_number_range", "Return candidate numbers only", "do not copy candidate names", "start_unit_id", "not candidate numbers"} {
if !strings.Contains(protocol, requirement) {
t.Fatalf("protocol message = %q, want requirement %q", protocol, requirement)
}
}
if !strings.Contains(prepared.Messages[3].Content, `"candidate_id":1`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
if !strings.Contains(prepared.Messages[3].Content, `"candidate_number":1`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
t.Fatalf("candidate message = %q, want only integer candidate material", prepared.Messages[3].Content)
}
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"candidate_id"`) {
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"candidate_number"`) {
t.Fatalf("transcript message = %q, want only transcript windows", prepared.Messages[4].Content)
}
}

View File

@@ -65,12 +65,13 @@ const (
// Result owns the safe plan and neutral diagnostics from one call.
type Result struct {
disposition ResultDisposition
plan Plan
issues []Issue
discardedGroupCount int
candidateMappings []CandidateMapping
modelCandidate *contracts.ModelCandidate
disposition ResultDisposition
plan Plan
issues []Issue
discardedGroupCount int
candidateNumberRange CandidateNumberRange
candidateMappings []CandidateMapping
modelCandidate *contracts.ModelCandidate
}
// Disposition returns the classified outcome.
@@ -85,11 +86,27 @@ func (result Result) Issues() []Issue { return append([]Issue(nil), result.issue
// DiscardedGroupCount returns the number of excluded proposal groups.
func (result Result) DiscardedGroupCount() int { return result.discardedGroupCount }
// CandidateMappings returns the request-local handle mapping used for this call.
// CandidateNumberRange returns the exact request-local range used for this call.
func (result Result) CandidateNumberRange() CandidateNumberRange {
return result.candidateNumberRange
}
// CandidateMappings returns the request-local number mapping used for this call.
func (result Result) CandidateMappings() []CandidateMapping {
return append([]CandidateMapping(nil), result.candidateMappings...)
}
// CorrectionDetails returns bounded model-facing descriptions of the result's
// proposal issues using the exact candidate-number range for this call.
func (result Result) CorrectionDetails() ([]string, error) {
return CorrectionDetails(result.Issues(), result.candidateNumberRange)
}
// CorrectionGuidance returns one bounded replacement request for this result.
func (result Result) CorrectionGuidance(additionalDetails ...string) (string, error) {
return CorrectionGuidance(result.Issues(), result.candidateNumberRange, additionalDetails...)
}
// ModelCandidate returns an owned copy of the proposal response when a model
// completion produced this result.
func (result Result) ModelCandidate() *contracts.ModelCandidate {
@@ -158,7 +175,10 @@ func (engine *Engine) Reconcile(ctx context.Context, request Request) (Result, e
if err != nil {
return Result{}, fmt.Errorf("semantic reconciliation %q: prepare materials: %w", request.StageName, err)
}
result := Result{candidateMappings: preparation.CandidateMappings()}
result := Result{
candidateNumberRange: preparation.CandidateNumberRange(),
candidateMappings: preparation.CandidateMappings(),
}
switch preparation.Disposition() {
case InsufficientCandidates:
result.disposition = SkippedInsufficientCandidates

View File

@@ -50,7 +50,7 @@ func TestNewEngineValidatesConstruction(t *testing.T) {
func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
client := &recordingReconciliationClient{responses: []ProposalResponse{{DuplicateGroups: []DuplicateGroup{{
CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2,
CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2,
}}}}}
engine := newTestEngine(t, client, DefaultLimits())
request := readyEngineRequest()
@@ -69,6 +69,9 @@ func TestEnginePropagatesRequestAndAssessesResponse(t *testing.T) {
if result.Disposition() != Complete || result.DiscardedGroupCount() != 0 || len(result.Issues()) != 0 {
t.Fatalf("result = disposition %v discarded %d issues %#v", result.Disposition(), result.DiscardedGroupCount(), result.Issues())
}
if got, want := result.CandidateNumberRange(), (CandidateNumberRange{First: 1, Last: 2}); got != want {
t.Fatalf("candidate number range = %#v, want %#v", got, want)
}
groups := result.Plan().Groups()
if len(groups) != 1 || !reflect.DeepEqual(groups[0].MemberPositions(), []int{0, 1}) || groups[0].CanonicalPosition() != 1 {
t.Fatalf("safe plan = %#v", groups)
@@ -105,7 +108,7 @@ func TestEngineClassifiesSemanticAndTransportOutcomes(t *testing.T) {
wantError error
}{
{name: "empty groups complete", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{}}, want: Complete, wantCandidate: true},
{name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true, wantCandidate: true},
{name: "discarded proposal retryable", response: ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1}}}, want: RetryableDiscardedProposalGroups, wantDiscard: 1, wantIssues: true, wantCandidate: true},
{name: "invalid structured output retryable", completion: fmt.Errorf("decode response: %w", contracts.ErrInvalidStructuredOutput), want: RetryableInvalidStructuredOutput},
{name: "transport failure", completion: transportErr, wantError: transportErr},
}
@@ -213,8 +216,8 @@ func TestEngineRejectsInvalidInvocationAndHonorsCancellation(t *testing.T) {
func TestEngineCallsAreIndependentAndResultsAreOwned(t *testing.T) {
client := &recordingReconciliationClient{responses: []ProposalResponse{
{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1},
{CandidateIDs: []int{2, 99}, CanonicalCandidateID: 2},
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 1},
{CandidateNumbers: []int{2, 99}, CanonicalCandidateNumber: 2},
}},
{DuplicateGroups: []DuplicateGroup{}},
}}
@@ -280,7 +283,7 @@ func cloneProposalResponse(response ProposalResponse) ProposalResponse {
cloned := ProposalResponse{DuplicateGroups: make([]DuplicateGroup, len(response.DuplicateGroups))}
for index, group := range response.DuplicateGroups {
cloned.DuplicateGroups[index] = DuplicateGroup{
CandidateIDs: append([]int(nil), group.CandidateIDs...), CanonicalCandidateID: group.CanonicalCandidateID,
CandidateNumbers: append([]int(nil), group.CandidateNumbers...), CanonicalCandidateNumber: group.CanonicalCandidateNumber,
}
}
return cloned

View File

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

View File

@@ -70,18 +70,26 @@ const (
LimitExceeded
)
// CandidateMapping relates one model-visible request-local ID to the
// CandidateMapping relates one model-visible request-local number to the
// corresponding zero-based position in the caller's candidate slice.
type CandidateMapping struct {
CandidateID int
CandidateNumber int
CandidatePosition int
}
// CandidateNumberRange is the exact inclusive request-local number range
// presented to the model. Ready preparations always use a one-based range.
type CandidateNumberRange struct {
First int `json:"first"`
Last int `json:"last"`
}
// Preparation owns the visible candidate mapping and prompt materials.
type Preparation struct {
disposition Disposition
mappings []CandidateMapping
materials contracts.LLMInputSet
disposition Disposition
candidateNumberRange CandidateNumberRange
mappings []CandidateMapping
materials contracts.LLMInputSet
}
// Disposition returns the preparation outcome.
@@ -94,6 +102,12 @@ func (preparation Preparation) CandidateMappings() []CandidateMapping {
return append([]CandidateMapping(nil), preparation.mappings...)
}
// CandidateNumberRange returns the exact request-local range assigned during
// preparation. A zero value means that no eligible candidates were present.
func (preparation Preparation) CandidateNumberRange() CandidateNumberRange {
return preparation.candidateNumberRange
}
// Materials returns independently owned candidate and transcript materials.
// It is empty unless Disposition returns Ready.
func (preparation Preparation) Materials() contracts.LLMInputSet {
@@ -106,13 +120,14 @@ type sourceRange struct {
}
type visibleCandidate struct {
CandidateID int `json:"candidate_id"`
Label string `json:"label"`
SourceRefs []sourceRange `json:"source_refs"`
CandidateNumber int `json:"candidate_number"`
Label string `json:"label"`
SourceRefs []sourceRange `json:"source_refs"`
}
type candidateInput struct {
Candidates []visibleCandidate `json:"candidates"`
CandidateNumberRange CandidateNumberRange `json:"candidate_number_range"`
Candidates []visibleCandidate `json:"candidates"`
}
type transcriptInput struct {
@@ -168,17 +183,20 @@ func Prepare(document *source.SourceDocument, candidates []Candidate, limits Lim
disposition: InsufficientCandidates,
mappings: make([]CandidateMapping, len(prepared)),
}
if len(prepared) > 0 {
result.candidateNumberRange = CandidateNumberRange{First: 1, Last: len(prepared)}
}
views := make([]visibleCandidate, len(prepared))
for index, candidate := range prepared {
candidateID := index + 1
candidateNumber := index + 1
result.mappings[index] = CandidateMapping{
CandidateID: candidateID,
CandidateNumber: candidateNumber,
CandidatePosition: candidate.position,
}
views[index] = visibleCandidate{
CandidateID: candidateID,
Label: candidates[candidate.position].Label,
SourceRefs: cloneSourceRanges(candidate.references),
CandidateNumber: candidateNumber,
Label: candidates[candidate.position].Label,
SourceRefs: cloneSourceRanges(candidate.references),
}
}
if len(prepared) < 2 {
@@ -189,7 +207,7 @@ func Prepare(document *source.SourceDocument, candidates []Candidate, limits Lim
return result, nil
}
candidateContent, withinLimit, err := marshalCandidateInput(views, limits.MaximumMaterialBytes)
candidateContent, withinLimit, err := marshalCandidateInput(result.candidateNumberRange, views, limits.MaximumMaterialBytes)
if err != nil {
return Preparation{}, fmt.Errorf("prepare semantic reconciliation: encode candidate material: %w", err)
}
@@ -302,10 +320,14 @@ func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
return coalesced
}
func marshalCandidateInput(candidates []visibleCandidate, maximumBytes int) ([]byte, bool, error) {
func marshalCandidateInput(numberRange CandidateNumberRange, candidates []visibleCandidate, maximumBytes int) ([]byte, bool, error) {
content := make([]byte, 0, min(maximumBytes, 4096))
encodedRange, err := json.Marshal(numberRange)
if err != nil {
return nil, false, err
}
var withinLimit bool
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidates":[`))
content, withinLimit = appendWithinLimit(content, maximumBytes, []byte(`{"candidate_number_range":`), encodedRange, []byte(`,"candidates":[`))
if !withinLimit {
return nil, false, nil
}

View File

@@ -47,11 +47,14 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) {
t.Fatalf("Prepare() mutated candidates: %#v", candidates)
}
if got, want := preparation.CandidateMappings(), []CandidateMapping{
{CandidateID: 1, CandidatePosition: 0},
{CandidateID: 2, CandidatePosition: 1},
{CandidateNumber: 1, CandidatePosition: 0},
{CandidateNumber: 2, CandidatePosition: 1},
}; !reflect.DeepEqual(got, want) {
t.Fatalf("CandidateMappings() = %#v, want %#v", got, want)
}
if got, want := preparation.CandidateNumberRange(), (CandidateNumberRange{First: 1, Last: 2}); got != want {
t.Fatalf("CandidateNumberRange() = %#v, want %#v", got, want)
}
materials := preparation.Materials()
if len(materials) != 2 {
@@ -79,12 +82,15 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) {
t.Fatal(err)
}
wantCandidates := []visibleCandidate{
{CandidateID: 1, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}},
{CandidateID: 2, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}},
{CandidateNumber: 1, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}},
{CandidateNumber: 2, Label: "The Tavern", SourceRefs: []sourceRange{{StartUnitID: 10, EndUnitID: 20}, {StartUnitID: 90, EndUnitID: 90}}},
}
if !reflect.DeepEqual(candidatePayload.Candidates, wantCandidates) {
t.Fatalf("candidate payload = %#v, want %#v", candidatePayload.Candidates, wantCandidates)
}
if got, want := candidatePayload.CandidateNumberRange, (CandidateNumberRange{First: 1, Last: 2}); got != want {
t.Fatalf("candidate number range = %#v, want %#v", got, want)
}
var candidateObjects struct {
Candidates []map[string]json.RawMessage `json:"candidates"`
}
@@ -92,12 +98,12 @@ func TestPrepareBuildsContiguousCandidatesAndOwnedSourceContext(t *testing.T) {
t.Fatal(err)
}
for _, candidate := range candidateObjects.Candidates {
if len(candidate) != 3 || candidate["candidate_id"] == nil || candidate["label"] == nil || candidate["source_refs"] == nil {
if len(candidate) != 3 || candidate["candidate_number"] == nil || candidate["label"] == nil || candidate["source_refs"] == nil {
t.Fatalf("model-facing candidate fields = %#v", candidate)
}
}
combined := string(materials[candidateInputName].Content) + string(materials[transcriptInputName].Content)
for _, forbidden := range []string{document.ID, "application_entity_id", "private-entity-id"} {
for _, forbidden := range []string{document.ID, "application_entity_id", "private-entity-id", `"candidate_id"`, `"candidate_ids"`, `"canonical_candidate_id"`} {
if strings.Contains(combined, forbidden) {
t.Fatalf("model material leaked %q: %s", forbidden, combined)
}
@@ -192,11 +198,14 @@ func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T)
t.Fatalf("Prepare() disposition = %v, error = %v", preparation.Disposition(), err)
}
if got, want := preparation.CandidateMappings(), []CandidateMapping{
{CandidateID: 1, CandidatePosition: 0},
{CandidateID: 2, CandidatePosition: 1},
{CandidateNumber: 1, CandidatePosition: 0},
{CandidateNumber: 2, CandidatePosition: 1},
}; !reflect.DeepEqual(got, want) {
t.Fatalf("CandidateMappings() = %#v, want %#v", got, want)
}
if got, want := preparation.CandidateNumberRange(), (CandidateNumberRange{First: 1, Last: 2}); got != want {
t.Fatalf("CandidateNumberRange() after filtering = %#v, want %#v", got, want)
}
var transcript transcriptInput
if err := json.Unmarshal(preparation.Materials()[transcriptInputName].Content, &transcript); err != nil {
t.Fatal(err)
@@ -208,7 +217,7 @@ func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T)
if err != nil {
t.Fatal(err)
}
if got, want := oneCandidate.CandidateMappings(), []CandidateMapping{{CandidateID: 1, CandidatePosition: 0}}; oneCandidate.Disposition() != InsufficientCandidates || !reflect.DeepEqual(got, want) || len(oneCandidate.Materials()) != 0 {
if got, want := oneCandidate.CandidateMappings(), []CandidateMapping{{CandidateNumber: 1, CandidatePosition: 0}}; oneCandidate.Disposition() != InsufficientCandidates || !reflect.DeepEqual(got, want) || oneCandidate.CandidateNumberRange() != (CandidateNumberRange{First: 1, Last: 1}) || len(oneCandidate.Materials()) != 0 {
t.Fatalf("Prepare(one candidate) = disposition %v, mappings %#v, materials %#v", oneCandidate.Disposition(), got, oneCandidate.Materials())
}
@@ -216,7 +225,7 @@ func TestPrepareFiltersUnsafeCandidatesAndCoalescesAdjacentWindows(t *testing.T)
if err != nil {
t.Fatal(err)
}
if nilPreparation.Disposition() != InsufficientCandidates || len(nilPreparation.CandidateMappings()) != 0 || len(nilPreparation.Materials()) != 0 {
if nilPreparation.Disposition() != InsufficientCandidates || len(nilPreparation.CandidateMappings()) != 0 || nilPreparation.CandidateNumberRange() != (CandidateNumberRange{}) || len(nilPreparation.Materials()) != 0 {
t.Fatalf("Prepare(nil) = disposition %v, mappings %#v, materials %#v", nilPreparation.Disposition(), nilPreparation.CandidateMappings(), nilPreparation.Materials())
}
}

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
}

View File

@@ -12,8 +12,8 @@ import (
func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) {
preparation := proposalPreparation(t)
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{5, 4}, CanonicalCandidateID: 5},
{CandidateIDs: []int{2, 1}, CanonicalCandidateID: 2},
{CandidateNumbers: []int{5, 4}, CanonicalCandidateNumber: 5},
{CandidateNumbers: []int{2, 1}, CanonicalCandidateNumber: 2},
}}
assessment := preparation.Assess(response)
want := []planGroupSnapshot{
@@ -28,8 +28,8 @@ func TestAssessProducesAStableOriginalPositionPlan(t *testing.T) {
}
reordered := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
{CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5},
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2},
{CandidateNumbers: []int{4, 5}, CanonicalCandidateNumber: 5},
}})
if got := snapshotPlan(reordered.Plan()); !reflect.DeepEqual(got, want) {
t.Fatalf("reordered plan = %#v, want %#v", got, want)
@@ -53,6 +53,7 @@ func TestIssueDetailsPreservesIssueOrder(t *testing.T) {
}
func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels(t *testing.T) {
numberRange := CandidateNumberRange{First: 1, Last: 8}
if len(issueCorrectionProse) != len(allIssueCategories) {
t.Fatalf("correction prose entries = %d, categories = %d", len(issueCorrectionProse), len(allIssueCategories))
}
@@ -69,15 +70,15 @@ func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels
issues[index] = Issue{GroupIndex: index, Category: category}
}
details, err := CorrectionDetails(issues)
details, err := CorrectionDetails(issues, numberRange)
if err != nil {
t.Fatalf("CorrectionDetails() error = %v", err)
}
guidance, err := CorrectionGuidance(issues)
guidance, err := CorrectionGuidance(issues, numberRange)
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 {
if len(details) != len(allIssueCategories)+1 || !strings.Contains(guidance, "Duplicate group 1") || !strings.Contains(guidance, "candidate-number range 1 through 8") || !strings.Contains(guidance, "start_unit_id") || !strings.Contains(guidance, "complete corrected JSON response") || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes {
t.Fatalf("correction details = %#v guidance = %q", details, guidance)
}
for _, category := range allIssueCategories {
@@ -88,20 +89,21 @@ func TestCorrectionGuidanceCoversEveryIssueCategoryWithoutExposingInternalLabels
}
func TestCorrectionDetailsDeduplicatesAndBoundsAffectedGroupLists(t *testing.T) {
numberRange := CandidateNumberRange{First: 1, Last: 256}
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)
details, err := CorrectionDetails(issues, numberRange)
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)
if len(details) != 2 || !strings.Contains(details[0], "additional affected group") || strings.Count(details[0], "Duplicate groups") != 1 || !strings.Contains(details[1], "source positions") {
t.Fatalf("CorrectionDetails() = %#v, want one bounded grouped detail and one namespace reminder", details)
}
guidance, err := CorrectionGuidance(issues)
guidance, err := CorrectionGuidance(issues, numberRange)
if err != nil || len(guidance) > contracts.MaxNormalizeRetryCorrectionGuidanceBytes {
t.Fatalf("CorrectionGuidance() = %q, %v", guidance, err)
}
@@ -112,10 +114,15 @@ func TestCorrectionDetailsRejectsUnknownOrInvalidIssues(t *testing.T) {
{{GroupIndex: 0, Category: "future_unmapped_category"}},
{{GroupIndex: -1, Category: IssueMemberUnknown}},
} {
if details, err := CorrectionDetails(issues); err == nil || details != nil {
if details, err := CorrectionDetails(issues, CandidateNumberRange{First: 1, Last: 2}); err == nil || details != nil {
t.Fatalf("CorrectionDetails(%#v) = %#v, %v; want fail-closed error", issues, details, err)
}
}
for _, numberRange := range []CandidateNumberRange{{}, {First: 0, Last: 2}, {First: 1, Last: 1}} {
if details, err := CorrectionDetails([]Issue{{GroupIndex: 0, Category: IssueMemberUnknown}}, numberRange); err == nil || details != nil {
t.Fatalf("CorrectionDetails(range %#v) = %#v, %v; want fail-closed error", numberRange, details, err)
}
}
}
func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
@@ -125,15 +132,15 @@ func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
group DuplicateGroup
category IssueCategory
}{
{name: "zero member", group: DuplicateGroup{CandidateIDs: []int{0, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive},
{name: "negative member", group: DuplicateGroup{CandidateIDs: []int{-1, 2}, CanonicalCandidateID: 2}, category: IssueMemberNonPositive},
{name: "unknown member", group: DuplicateGroup{CandidateIDs: []int{99, 2}, CanonicalCandidateID: 2}, category: IssueMemberUnknown},
{name: "repeated member", group: DuplicateGroup{CandidateIDs: []int{1, 1}, CanonicalCandidateID: 1}, category: IssueRepeatedMember},
{name: "too small", group: DuplicateGroup{CandidateIDs: []int{1}, CanonicalCandidateID: 1}, category: IssueFewerThanTwoMembers},
{name: "zero canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 0}, category: IssueCanonicalNonPositive},
{name: "negative canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: -1}, category: IssueCanonicalNonPositive},
{name: "unknown canonical", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 99}, category: IssueCanonicalUnknown},
{name: "canonical not member", group: DuplicateGroup{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 3}, category: IssueCanonicalNotMember},
{name: "zero member", group: DuplicateGroup{CandidateNumbers: []int{0, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberNonPositive},
{name: "negative member", group: DuplicateGroup{CandidateNumbers: []int{-1, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberNonPositive},
{name: "unknown member", group: DuplicateGroup{CandidateNumbers: []int{99, 2}, CanonicalCandidateNumber: 2}, category: IssueMemberUnknown},
{name: "repeated member", group: DuplicateGroup{CandidateNumbers: []int{1, 1}, CanonicalCandidateNumber: 1}, category: IssueRepeatedMember},
{name: "too small", group: DuplicateGroup{CandidateNumbers: []int{1}, CanonicalCandidateNumber: 1}, category: IssueFewerThanTwoMembers},
{name: "zero canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 0}, category: IssueCanonicalNonPositive},
{name: "negative canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: -1}, category: IssueCanonicalNonPositive},
{name: "unknown canonical", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 99}, category: IssueCanonicalUnknown},
{name: "canonical not member", group: DuplicateGroup{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 3}, category: IssueCanonicalNotMember},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -151,9 +158,9 @@ func TestAssessRejectsEveryUnsafeLocalGroupShape(t *testing.T) {
func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testing.T) {
preparation := proposalPreparation(t)
assessment := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 1},
{CandidateIDs: []int{2, 3}, CanonicalCandidateID: 2},
{CandidateIDs: []int{4, 5}, CanonicalCandidateID: 5},
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 1},
{CandidateNumbers: []int{2, 3}, CanonicalCandidateNumber: 2},
{CandidateNumbers: []int{4, 5}, CanonicalCandidateNumber: 5},
}})
wantPlan := []planGroupSnapshot{{members: []int{5, 6}, canonical: 6}}
if got := snapshotPlan(assessment.Plan()); !reflect.DeepEqual(got, wantPlan) {
@@ -168,8 +175,8 @@ func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testi
}
invalidAndSafe := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1},
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
{CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1},
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2},
}})
wantPlan = []planGroupSnapshot{{members: []int{0, 2}, canonical: 2}}
if got := snapshotPlan(invalidAndSafe.Plan()); !reflect.DeepEqual(got, wantPlan) {
@@ -183,13 +190,13 @@ func TestAssessDiscardsEveryOverlappingGroupAndRetainsIndependentGroups(t *testi
func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) {
preparation := proposalPreparation(t)
response := ProposalResponse{DuplicateGroups: []DuplicateGroup{
{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2},
{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2},
}}
assessment := preparation.Assess(response)
want := snapshotPlan(assessment.Plan())
response.DuplicateGroups[0].CandidateIDs[0] = 99
response.DuplicateGroups[0].CanonicalCandidateID = 99
response.DuplicateGroups[0].CandidateNumbers[0] = 99
response.DuplicateGroups[0].CanonicalCandidateNumber = 99
plan := assessment.Plan()
groups := plan.Groups()
members := groups[0].MemberPositions()
@@ -200,7 +207,7 @@ func TestAssessmentAccessorsAndInputsDoNotShareRetainedState(t *testing.T) {
}
invalid := preparation.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
CandidateIDs: []int{1, 99}, CanonicalCandidateID: 1,
CandidateNumbers: []int{1, 99}, CanonicalCandidateNumber: 1,
}}})
issues := invalid.Issues()
issues[0].GroupIndex = 99

View File

@@ -44,20 +44,21 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) {
valid bool
}{
{name: "empty proposal", value: map[string]any{"duplicate_groups": []any{}}, valid: true},
{name: "valid group", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1}}}, valid: true},
{name: "semantic canonical mismatch", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 3}}}, valid: true},
{name: "valid group", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 1}}}, valid: true},
{name: "semantic canonical mismatch", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 3}}}, valid: true},
{name: "missing proposal", value: map[string]any{}, valid: false},
{name: "unknown top-level field", value: map[string]any{"duplicate_groups": []any{}, "extra": true}, valid: false},
{name: "missing members", value: map[string]any{"duplicate_groups": []any{map[string]any{"canonical_candidate_id": 1}}}, valid: false},
{name: "missing canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}}}}, valid: false},
{name: "unknown group field", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1, "name": "replacement"}}}, valid: false},
{name: "too few members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "semantic repeated members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 1}, "canonical_candidate_id": 1}}}, valid: true},
{name: "zero member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{0, 1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{-1, 1}, "canonical_candidate_id": 1}}}, valid: false},
{name: "non-integer member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2.5}, "canonical_candidate_id": 1}}}, valid: false},
{name: "zero canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 0}}}, valid: false},
{name: "contextual selectors", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1, "source_refs": []any{}}}}, valid: false},
{name: "missing members", value: map[string]any{"duplicate_groups": []any{map[string]any{"canonical_candidate_number": 1}}}, valid: false},
{name: "missing canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}}}}, valid: false},
{name: "unknown group field", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 1, "name": "replacement"}}}, valid: false},
{name: "former candidate ID fields", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_ids": []any{1, 2}, "canonical_candidate_id": 1}}}, valid: false},
{name: "too few members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1}, "canonical_candidate_number": 1}}}, valid: false},
{name: "semantic repeated members", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 1}, "canonical_candidate_number": 1}}}, valid: true},
{name: "zero member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{0, 1}, "canonical_candidate_number": 1}}}, valid: false},
{name: "negative member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{-1, 1}, "canonical_candidate_number": 1}}}, valid: false},
{name: "non-integer member", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2.5}, "canonical_candidate_number": 1}}}, valid: false},
{name: "zero canonical", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 0}}}, valid: false},
{name: "contextual selectors", value: map[string]any{"duplicate_groups": []any{map[string]any{"candidate_numbers": []any{1, 2}, "canonical_candidate_number": 1, "source_refs": []any{}}}}, valid: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -72,7 +73,7 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) {
})
}
content := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`)
content := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`)
if err := validateAgainstSchema(content, schema.JSONSchema); err != nil {
t.Fatal(err)
}
@@ -80,7 +81,7 @@ func TestResponseSchemaAcceptsOnlyTheIntegerProposalShape(t *testing.T) {
if err := json.Unmarshal(content, &response); err != nil {
t.Fatal(err)
}
want := ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateIDs: []int{1, 2}, CanonicalCandidateID: 2}}}
want := ProposalResponse{DuplicateGroups: []DuplicateGroup{{CandidateNumbers: []int{1, 2}, CanonicalCandidateNumber: 2}}}
if !reflect.DeepEqual(response, want) {
t.Fatalf("decoded response = %#v, want %#v", response, want)
}

View File

@@ -156,7 +156,7 @@ func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, findings []
func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostics.Finding, reconciliation semanticreconcile.Result, rejectedGroups int) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) {
issues := reconciliation.Issues()
operatorDetails := semanticreconcile.IssueDetails(issues)
correctionDetails, err := semanticreconcile.CorrectionDetails(issues)
correctionDetails, err := reconciliation.CorrectionDetails()
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
}
@@ -167,7 +167,7 @@ func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostic
correctionDetails = append(correctionDetails, currencyGuidance)
additionalGuidance = append(additionalGuidance, currencyGuidance)
}
correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues, additionalGuidance...)
correctionGuidance, err := reconciliation.CorrectionGuidance(additionalGuidance...)
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err)
}

View File

@@ -122,7 +122,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
{Name: "Compass of the Stars", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil || len(result.Value.Items) != 2 {
t.Fatalf("Normalize() = %#v, %v", result, err)
@@ -159,7 +159,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Piece", SourceRefs: ref(20)},
{Name: "Gold Pieces", SourceRefs: ref(30)},
},
response: `{"duplicate_groups":[{"candidate_ids":[1,2,3],"canonical_candidate_id":2}]}`,
response: `{"duplicate_groups":[{"candidate_numbers":[1,2,3],"canonical_candidate_number":2}]}`,
wantNames: []string{"Gold Piece"},
wantRefCounts: []int{3},
reasonCode: ReasonCodeDuplicateItemCollapsed,
@@ -171,7 +171,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Silver Pieces", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`,
response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1}]}`,
wantNames: []string{"Gold Pieces", "Silver Pieces"},
wantRefCounts: []int{1, 1},
wantRetry: true,
@@ -184,7 +184,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`,
response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1}]}`,
wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1},
wantRetry: true,
@@ -197,7 +197,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Star Compass", SourceRefs: ref(10)},
{Name: "Compass of the Stars", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`,
response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`,
wantNames: []string{"Compass of the Stars"},
wantRefCounts: []int{2},
reasonCode: ReasonCodeDuplicateItemCollapsed,
@@ -209,7 +209,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`,
response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`,
wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1},
wantRetry: true,
@@ -253,7 +253,7 @@ func TestNormalizePreservesCandidatesForUnsafeProposalGroups(t *testing.T) {
{Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1},{"candidate_ids":[2,3],"canonical_candidate_id":3},{"candidate_ids":[1,3],"canonical_candidate_id":99}]}`}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1},{"candidate_numbers":[2,3],"canonical_candidate_number":3},{"candidate_numbers":[1,3],"canonical_candidate_number":99}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.Items) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") || !strings.Contains(result.Retry.Message, "canonical_unknown") || strings.Contains(result.Retry.Message, "Star Compass") || len(result.Retry.Message) > 4096 {
t.Fatalf("Normalize() = %#v, %v; want deterministic retry fallback", result, err)
@@ -272,7 +272,7 @@ func TestNormalizeAppliesIndependentGroupAndCountsAllOmissions(t *testing.T) {
{Name: "Silver Pieces", SourceRefs: ref(30)},
{Name: "Rope", SourceRefs: ref(10)},
}}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,4],"canonical_candidate_id":3},{"candidate_ids":[5,99],"canonical_candidate_id":5}]}`}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,4],"canonical_candidate_number":3},{"candidate_numbers":[5,99],"canonical_candidate_number":5}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil {
t.Fatalf("Normalize() = %#v, %v; want retry with independently accepted output", result, err)
@@ -289,7 +289,7 @@ func TestNormalizeAppliesIndependentGroupAndCountsAllOmissions(t *testing.T) {
if !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateItemCollapsed, contracts.DiagnosticDispositionObservation) || !hasDiagnostic(result.Diagnostics, ReasonCodeItemSemanticProposalInvalid, contracts.DiagnosticDispositionAdvisory) {
t.Fatalf("diagnostics = %#v, want accepted and guarded-group diagnostics", result.Diagnostics)
}
if !strings.Contains(result.Retry.CorrectionGuidance, "aliases of the same denomination") || !strings.Contains(result.Retry.CorrectionGuidance, "do not combine currency with non-currency items") || !strings.Contains(result.Retry.CorrectionGuidance, "supplied candidate list") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeItemSemanticRetryProposalInvalid) {
if !strings.Contains(result.Retry.CorrectionGuidance, "aliases of the same denomination") || !strings.Contains(result.Retry.CorrectionGuidance, "do not combine currency with non-currency items") || !strings.Contains(result.Retry.CorrectionGuidance, "candidate-number range 1 through 5") || !strings.Contains(result.Retry.CorrectionGuidance, "source positions") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeItemSemanticRetryProposalInvalid) {
t.Fatalf("correction guidance = %q, want shared and item-specific prose only", result.Retry.CorrectionGuidance)
}
if len(result.Retry.FallbackDiagnostics) != 1 || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "2 proposal group(s)") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "same denomination") {
@@ -355,11 +355,11 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":1},"candidates":[{"candidate_number":1,"label":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil {
t.Fatal(err)
}
if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") || !strings.Contains(prepared.Messages[2].Content, "currency denominations") || !strings.Contains(prepared.Messages[2].Content, "materially different item") || prepared.Messages[2].CacheControl == nil || prepared.Messages[2].CacheControl.Type != promptkit.CacheControlEphemeral {
if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_number_range") || !strings.Contains(prepared.Messages[1].Content, "start_unit_id") || !strings.Contains(prepared.Messages[2].Content, "currency denominations") || !strings.Contains(prepared.Messages[2].Content, "materially different item") || !strings.Contains(prepared.Messages[2].Content, "canonical_candidate_number") || prepared.Messages[2].CacheControl == nil || prepared.Messages[2].CacheControl.Type != promptkit.CacheControlEphemeral {
t.Fatalf("prepared prompt = %#v", prepared)
}
if prepared.Messages[4].CacheControl == nil || prepared.Messages[4].CacheControl.Type != promptkit.CacheControlEphemeral || !strings.Contains(prepared.Messages[3].Content, `"Rope"`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) || !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"Rope"`) {

View File

@@ -154,11 +154,11 @@ func (n *Normalizer) invalidStructuredResult(value dnd.LocationRegistry, finding
func retryResult(value dnd.LocationRegistry, findings []diagnostics.Finding, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) {
issues := reconciliation.Issues()
correctionDetails, err := semanticreconcile.CorrectionDetails(issues)
correctionDetails, err := reconciliation.CorrectionDetails()
if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
}
correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues)
correctionGuidance, err := reconciliation.CorrectionGuidance()
if err != nil {
return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err)
}

View File

@@ -95,7 +95,7 @@ func BenchmarkExactDuplicateGroupsManyDistinct(b *testing.B) {
}
func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) {
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`}
doc := semanticDocument()
input := dnd.LocationRegistry{Locations: []dnd.Location{
{Name: "Old Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
@@ -123,7 +123,7 @@ func TestNormalizeRejectsUnsafeAndOverlappingGroupsWithoutLosingCandidates(t *te
{Name: "Mill", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}}
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1},{"candidate_ids":[2,3],"canonical_candidate_id":3}]}`}
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1},{"candidate_numbers":[2,3],"canonical_candidate_number":3}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.Locations) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") {
t.Fatalf("Normalize() = %#v, %v; want safe retry fallback", result, err)
@@ -152,14 +152,14 @@ func TestReconciliationCandidatesKeepSameNameEvidenceDistinct(t *testing.T) {
}
var candidateInput struct {
Candidates []struct {
CandidateID int `json:"candidate_id"`
Label string `json:"label"`
CandidateNumber int `json:"candidate_number"`
Label string `json:"label"`
} `json:"candidates"`
}
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
t.Fatal(err)
}
if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateID != 1 || candidateInput.Candidates[1].CandidateID != 2 || candidateInput.Candidates[0].Label != "The Tavern" || candidateInput.Candidates[1].Label != "The Tavern" {
if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateNumber != 1 || candidateInput.Candidates[1].CandidateNumber != 2 || candidateInput.Candidates[0].Label != "The Tavern" || candidateInput.Candidates[1].Label != "The Tavern" {
t.Fatalf("candidate input = %#v, want distinct integer handles for equal names", candidateInput)
}
}

View File

@@ -28,11 +28,11 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
if err != nil {
t.Fatal(err)
}
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":1},"candidates":[{"candidate_number":1,"label":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil {
t.Fatal(err)
}
if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") || !strings.Contains(prepared.Messages[2].Content, "same physical place") || !strings.Contains(prepared.Messages[2].Content, "parent and child places") {
if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_number_range") || !strings.Contains(prepared.Messages[1].Content, "start_unit_id") || !strings.Contains(prepared.Messages[2].Content, "same physical place") || !strings.Contains(prepared.Messages[2].Content, "parent and child places") || !strings.Contains(prepared.Messages[2].Content, "canonical_candidate_number") {
t.Fatalf("prepared prompt = %#v", prepared)
}
for _, index := range []int{2, 4} {

View File

@@ -153,11 +153,11 @@ func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, findings []d
func retryResult(value dnd.NPCRegistry, findings []diagnostics.Finding, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
issues := reconciliation.Issues()
correctionDetails, err := semanticreconcile.CorrectionDetails(issues)
correctionDetails, err := reconciliation.CorrectionDetails()
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic correction details: %w", err)
}
correctionGuidance, err := semanticreconcile.CorrectionGuidance(issues)
correctionGuidance, err := reconciliation.CorrectionGuidance()
if err != nil {
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("build semantic correction guidance: %w", err)
}

View File

@@ -36,7 +36,7 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "normalize-test-profile",
Inputs: map[string]promptkit.ArtifactRef{
"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"candidates": promptkit.Inline(`{"candidate_number_range":{"first":1,"last":1},"candidates":[{"candidate_number":1,"label":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
},
})
@@ -49,10 +49,10 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
if prepared.Messages[0].Role != "system" {
t.Fatalf("initial message role = %q, want system", prepared.Messages[0].Role)
}
if !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") {
if !strings.Contains(prepared.Messages[1].Content, "candidate_number") || !strings.Contains(prepared.Messages[1].Content, "integer") {
t.Fatalf("protocol message = %q, want shared integer-handle protocol", prepared.Messages[1].Content)
}
if !strings.Contains(prepared.Messages[2].Content, "same individual") || strings.Contains(prepared.Messages[2].Content, "source ranges") {
if !strings.Contains(prepared.Messages[2].Content, "same individual") || !strings.Contains(prepared.Messages[2].Content, "canonical_candidate_number") || strings.Contains(prepared.Messages[2].Content, "source ranges") {
t.Fatalf("NPC policy message = %q, want domain distinctions without copied ranges", prepared.Messages[2].Content)
}
for _, index := range []int{2, 4} {

View File

@@ -35,7 +35,7 @@ func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing
}
func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -88,19 +88,19 @@ func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
}
var visible struct {
Candidates []struct {
CandidateID int `json:"candidate_id"`
CandidateNumber int `json:"candidate_number"`
} `json:"candidates"`
}
if err := json.Unmarshal(completion.Inputs["candidates"].Content, &visible); err != nil {
t.Fatalf("decode candidates: %v", err)
}
if got := []int{visible.Candidates[0].CandidateID, visible.Candidates[1].CandidateID, visible.Candidates[2].CandidateID}; !reflect.DeepEqual(got, []int{1, 2, 3}) {
t.Fatalf("candidate IDs = %v, want contiguous request-local handles", got)
if got := []int{visible.Candidates[0].CandidateNumber, visible.Candidates[1].CandidateNumber, visible.Candidates[2].CandidateNumber}; !reflect.DeepEqual(got, []int{1, 2, 3}) {
t.Fatalf("candidate numbers = %v, want contiguous request-local handles", got)
}
}
func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`}
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -115,13 +115,13 @@ func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
if result.Retry.ReasonCode != ReasonCodeNPCSemanticProposalInvalid || !strings.Contains(result.Retry.Message, "group 1: member_unknown") {
t.Fatalf("retry = %#v, want bounded invalid-proposal diagnostics", result.Retry)
}
if !strings.Contains(result.Retry.CorrectionGuidance, "Duplicate group 2") || !strings.Contains(result.Retry.CorrectionGuidance, "supplied candidate list") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeNPCSemanticProposalInvalid) {
if !strings.Contains(result.Retry.CorrectionGuidance, "Duplicate group 2") || !strings.Contains(result.Retry.CorrectionGuidance, "candidate-number range 1 through 3") || !strings.Contains(result.Retry.CorrectionGuidance, "source positions") || strings.Contains(result.Retry.CorrectionGuidance, "member_unknown") || strings.Contains(result.Retry.CorrectionGuidance, ReasonCodeNPCSemanticProposalInvalid) {
t.Fatalf("correction guidance = %q, want contextual prose without internal labels", result.Retry.CorrectionGuidance)
}
if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Mira Thorn" || result.Value.NPCs[1].Name != "Captain Vale" {
t.Fatalf("fallback NPCs = %#v, want independently safe group applied", result.Value.NPCs)
}
if len(result.Retry.FallbackDiagnostics) != 1 || result.Retry.FallbackDiagnostics[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "1 proposal group") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "supplied candidate list") {
if len(result.Retry.FallbackDiagnostics) != 1 || result.Retry.FallbackDiagnostics[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "1 proposal group") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "preserved separately") || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "candidate-number range 1 through 3") {
t.Fatalf("fallback diagnostics = %#v, want exact omitted-group warning", result.Retry.FallbackDiagnostics)
}
}
@@ -184,8 +184,8 @@ func TestNormalizeRedactsContextMaterialFailures(t *testing.T) {
func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
client := &recordingNPCNormalizerClient{responses: []string{
`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`,
`{"duplicate_groups":[{"candidate_ids":[1,3],"canonical_candidate_id":3}]}`,
`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`,
`{"duplicate_groups":[{"candidate_numbers":[1,3],"canonical_candidate_number":3}]}`,
}}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
@@ -208,7 +208,7 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
}
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownHandle(t *testing.T) {
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,99],"canonical_candidate_id":1}]}`}
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"candidate_numbers":[1,99],"canonical_candidate_number":1}]}`}
normalizer := newNormalizer(t, client)
doc := semanticDocument()
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
@@ -241,14 +241,14 @@ func TestReconciliationCandidatesKeepEqualContextualDescriptorsDistinct(t *testi
}
var candidateInput struct {
Candidates []struct {
CandidateID int `json:"candidate_id"`
Label string `json:"label"`
CandidateNumber int `json:"candidate_number"`
Label string `json:"label"`
} `json:"candidates"`
}
if err := json.Unmarshal(preparation.Materials()["candidates"].Content, &candidateInput); err != nil {
t.Fatal(err)
}
if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateID != 1 || candidateInput.Candidates[1].CandidateID != 2 || candidateInput.Candidates[0].Label != "The Guard" || candidateInput.Candidates[1].Label != "The Guard" {
if len(candidateInput.Candidates) != 2 || candidateInput.Candidates[0].CandidateNumber != 1 || candidateInput.Candidates[1].CandidateNumber != 2 || candidateInput.Candidates[0].Label != "The Guard" || candidateInput.Candidates[1].Label != "The Guard" {
t.Fatalf("candidate input = %#v, want distinct integer handles for equal descriptors", candidateInput)
}
}

View File

@@ -297,7 +297,7 @@ func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context,
}
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
case npcnormalize.PromptID:
content := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`)
content := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":1}]}`)
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, err
}

View File

@@ -181,8 +181,8 @@ func TestProductionNPCNormalizationRetryUsesFinalSafeProposal(t *testing.T) {
{Name: "Mira Thorn", SourceRefs: []npcProductionSourceRef{{StartUnitID: 2, EndUnitID: 2}}},
{Name: "Hooded Guard", SourceRefs: []npcProductionSourceRef{{StartUnitID: 3, EndUnitID: 3}}},
}}
partial := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,99],"canonical_candidate_id":3}]}`)
safe := []byte(`{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`)
partial := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2},{"candidate_numbers":[3,99],"canonical_candidate_number":3}]}`)
safe := []byte(`{"duplicate_groups":[{"candidate_numbers":[1,2],"canonical_candidate_number":2}]}`)
for _, test := range []struct {
name string