Reject unsafe currency reconciliation proposals

This commit is contained in:
2026-08-06 13:31:15 +00:00
parent 2ee6b495e1
commit 8cf03a2a44
4 changed files with 119 additions and 13 deletions

View File

@@ -158,7 +158,7 @@ func retryResult(value dnd.ItemRegistry, warnings []contracts.Warning, assessmen
func currencyRetryResult(value dnd.ItemRegistry, warnings []contracts.Warning, rejectedGroups int) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: value, Warnings: limitWarningsForRetry(warnings), Retry: &contracts.NormalizeRetry{
ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "semantic proposal requires retry: currency denominations must remain distinct",
ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "semantic proposal requires retry: currency may only be consolidated with aliases of one denomination",
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(rejectedGroups)},
}}
}

View File

@@ -84,7 +84,7 @@ func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing
}
}
func TestNormalizeAppliesSafeAliasProposalAndPreservesCurrency(t *testing.T) {
func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
doc := semanticDocument()
input := dnd.ItemRegistry{Items: []dnd.Item{
{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
@@ -104,11 +104,108 @@ func TestNormalizeAppliesSafeAliasProposalAndPreservesCurrency(t *testing.T) {
if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) {
t.Fatalf("private inputs = %s", encoded)
}
currencyClient := &recordingNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`}
currency := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Silver Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}}
result, err = newNormalizer(t, currencyClient).Normalize(context.Background(), normalizeRequestWithSource(currency, doc))
if err != nil || result.Retry == nil || len(result.Value.Items) != 2 || !hasWarning(result.Warnings, ReasonCodeItemSemanticProposalInvalid) {
t.Fatalf("currency result = %#v, %v; want denominations retained", result, err)
}
func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
doc := semanticDocument()
ref := func(unitID int) []source.SourceRef {
return []source.SourceRef{{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID}}
}
for _, test := range []struct {
name string
items []dnd.Item
response string
wantNames []string
wantRefCounts []int
wantRetry bool
warning string
}{
{
name: "same denomination aliases",
items: []dnd.Item{
{Name: "GP", SourceRefs: ref(10)},
{Name: "Gold Piece", SourceRefs: ref(20)},
{Name: "Gold Pieces", SourceRefs: ref(30)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002","candidate-000003"],"canonical":"candidate-000002"}]}`,
wantNames: []string{"Gold Piece"},
wantRefCounts: []int{3},
warning: ReasonCodeDuplicateItemCollapsed,
},
{
name: "different denominations",
items: []dnd.Item{
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Silver Pieces", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`,
wantNames: []string{"Gold Pieces", "Silver Pieces"},
wantRefCounts: []int{1, 1},
wantRetry: true,
warning: ReasonCodeItemSemanticProposalInvalid,
},
{
name: "currency plus ordinary item",
items: []dnd.Item{
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`,
wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1},
wantRetry: true,
warning: ReasonCodeItemSemanticProposalInvalid,
},
{
name: "ordinary items",
items: []dnd.Item{
{Name: "Star Compass", SourceRefs: ref(10)},
{Name: "Compass of the Stars", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`,
wantNames: []string{"Compass of the Stars"},
wantRefCounts: []int{2},
warning: ReasonCodeDuplicateItemCollapsed,
},
{
name: "currency plus ordinary canonical item",
items: []dnd.Item{
{Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)},
},
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`,
wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1},
wantRetry: true,
warning: ReasonCodeItemSemanticProposalInvalid,
},
} {
t.Run(test.name, func(t *testing.T) {
input := dnd.ItemRegistry{Items: make([]dnd.Item, len(test.items))}
for index, item := range test.items {
input.Items[index] = cloneItem(item)
}
before := dnd.ItemRegistry{Items: make([]dnd.Item, len(input.Items))}
for index, item := range input.Items {
before.Items[index] = cloneItem(item)
}
result, err := newNormalizer(t, &recordingNormalizerClient{response: test.response}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || (result.Retry != nil) != test.wantRetry || !reflect.DeepEqual(input, before) || !hasWarning(result.Warnings, test.warning) {
t.Fatalf("Normalize() = %#v, %v", result, err)
}
if len(result.Value.Items) != len(test.wantNames) {
t.Fatalf("items = %#v, want names %#v", result.Value.Items, test.wantNames)
}
for index, item := range result.Value.Items {
if item.Name != test.wantNames[index] || len(item.SourceRefs) != test.wantRefCounts[index] {
t.Fatalf("item %d = %#v, want name %q with %d source refs", index, item, test.wantNames[index], test.wantRefCounts[index])
}
}
if test.wantRetry && (len(result.Retry.FallbackWarnings) != 1 || result.Retry.FallbackWarnings[0].ReasonCode != ReasonCodeItemSemanticReconciliationExhausted) {
t.Fatalf("retry = %#v, want preserved-group fallback", result.Retry)
}
})
}
}

View File

@@ -82,7 +82,7 @@ func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGrou
for _, member := range group.members {
output = append(output, cloneRecord(records[member]))
}
warnings = append(warnings, contracts.Warning{Scope: itemScope(records[group.members[0]].earliest), ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "proposal group preserved because currency denominations must remain distinct"})
warnings = append(warnings, contracts.Warning{Scope: itemScope(records[group.members[0]].earliest), ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "proposal group preserved because currency may only be consolidated with aliases of one denomination"})
rejectedGroups++
continue
}
@@ -95,17 +95,23 @@ func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGrou
func canConsolidate(records []normalizedRecord, group safeReconciliationGroup) bool {
denomination := ""
hasCurrency := false
hasNonCurrency := false
hasConflictingDenominations := false
for _, member := range group.members {
current := currencyDenomination(records[member].item.Name)
if current == "" {
hasNonCurrency = true
continue
}
hasCurrency = true
if denomination != "" && denomination != current {
return false
hasConflictingDenominations = true
continue
}
denomination = current
}
return true
return !hasCurrency || (!hasNonCurrency && !hasConflictingDenominations)
}
func currencyDenomination(name string) string {