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

@@ -74,9 +74,12 @@ Normalization first applies deterministic display, evidence, and ID rules. It
then may use a bounded LLM-assisted proposal to reconcile semantically duplicate then may use a bounded LLM-assisted proposal to reconcile semantically duplicate
records. The proposal may choose only a supplied candidate display name; records. The proposal may choose only a supplied candidate display name;
invalid, uncertain, overlapping, or unsafe proposals retain the deterministic invalid, uncertain, overlapping, or unsafe proposals retain the deterministic
result with retry or fallback diagnostics. Currency denominations, materially result with retry or fallback diagnostics. A proposal that mixes a recognized
different item types, and merely nearby objects remain distinct. Source currency denomination with a non-currency item, or combines recognized
references establish registry provenance, not evidence for later artifacts. denominations, is unsafe and retains every deterministic record. Currency
denominations, materially different item types, and merely nearby objects
remain distinct. Source references establish registry provenance, not evidence
for later artifacts.
## Consumers and publication ## Consumers and publication

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] { 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{ 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)}, 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() doc := semanticDocument()
input := dnd.ItemRegistry{Items: []dnd.Item{ input := dnd.ItemRegistry{Items: []dnd.Item{
{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {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) { if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) {
t.Fatalf("private inputs = %s", encoded) 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)) func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
if err != nil || result.Retry == nil || len(result.Value.Items) != 2 || !hasWarning(result.Warnings, ReasonCodeItemSemanticProposalInvalid) { doc := semanticDocument()
t.Fatalf("currency result = %#v, %v; want denominations retained", result, err) 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 { for _, member := range group.members {
output = append(output, cloneRecord(records[member])) 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++ rejectedGroups++
continue continue
} }
@@ -95,17 +95,23 @@ func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGrou
func canConsolidate(records []normalizedRecord, group safeReconciliationGroup) bool { func canConsolidate(records []normalizedRecord, group safeReconciliationGroup) bool {
denomination := "" denomination := ""
hasCurrency := false
hasNonCurrency := false
hasConflictingDenominations := false
for _, member := range group.members { for _, member := range group.members {
current := currencyDenomination(records[member].item.Name) current := currencyDenomination(records[member].item.Name)
if current == "" { if current == "" {
hasNonCurrency = true
continue continue
} }
hasCurrency = true
if denomination != "" && denomination != current { if denomination != "" && denomination != current {
return false hasConflictingDenominations = true
continue
} }
denomination = current denomination = current
} }
return true return !hasCurrency || (!hasNonCurrency && !hasConflictingDenominations)
} }
func currencyDenomination(name string) string { func currencyDenomination(name string) string {