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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)},
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user