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
records. The proposal may choose only a supplied candidate display name;
invalid, uncertain, overlapping, or unsafe proposals retain the deterministic
result with retry or fallback diagnostics. Currency denominations, materially
different item types, and merely nearby objects remain distinct. Source
references establish registry provenance, not evidence for later artifacts.
result with retry or fallback diagnostics. A proposal that mixes a recognized
currency denomination with a non-currency item, or combines recognized
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

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 {