Reject unsafe currency reconciliation proposals
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user