Files
notarius/internal/modules/dnd/normalize/itemregistry/reconciliation.go

151 lines
5.5 KiB
Go

package itemregistry
import (
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
)
const incompatibleCurrencyGroup semanticreconcile.RejectionCategory = "incompatible_currency"
func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.Item], error) {
candidates := make([]semanticreconcile.Candidate, len(records))
envelopes := make([]semanticreconcile.Record[dnd.Item], len(records))
for index, record := range records {
candidates[index] = semanticreconcile.Candidate{
Label: record.item.Name,
SourceRefs: cloneSourceRefs(record.item.SourceRefs),
}
envelope, err := semanticreconcile.NewRecord(record.item, record.inputIndexes, record.earliest, cloneItem)
if err != nil {
return nil, nil, fmt.Errorf("record %d: %w", index, err)
}
envelopes[index] = envelope
}
return candidates, envelopes, nil
}
func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Item], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, int, error) {
application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Item]{
CloneValue: cloneItem,
RejectGroup: func(members []dnd.Item, _ dnd.Item) semanticreconcile.RejectionCategory {
if !canConsolidate(members) {
return incompatibleCurrencyGroup
}
return ""
},
ConsolidateGroup: func(members []dnd.Item, canonical dnd.Item) (dnd.Item, error) {
output := cloneItem(canonical)
output.SourceRefs = nil
for _, member := range members {
output.SourceRefs = append(output.SourceRefs, member.SourceRefs...)
}
output.SourceRefs = order.Canonicalize(output.SourceRefs)
output.ID = identity.DeriveID(output.Name)
return output, nil
},
})
if err != nil {
return nil, nil, 0, err
}
applied := application.Records()
output := make([]normalizedRecord, len(applied))
for index, record := range applied {
output[index] = normalizedRecord{
item: record.Value(),
inputIndexes: record.OriginalInputIndexes(),
earliest: record.EarliestInputPosition(),
}
}
type orderedWarning struct {
position int
warning contracts.Warning
}
orderedWarnings := make([]orderedWarning, 0, len(application.AppliedGroups())+len(application.RejectedGroups()))
for _, event := range application.AppliedGroups() {
provenance := event.Provenance()
orderedWarnings = append(orderedWarnings, orderedWarning{
position: provenance.EarliestInputPosition(),
warning: semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()]),
})
}
for _, event := range application.RejectedGroups() {
provenance := event.Provenance()
orderedWarnings = append(orderedWarnings, orderedWarning{
position: provenance.EarliestInputPosition(),
warning: contracts.Warning{
Scope: itemScope(provenance.EarliestInputPosition()),
ReasonCode: ReasonCodeItemSemanticProposalInvalid,
Message: "proposal group preserved because currency may only be consolidated with aliases of one denomination",
},
})
}
sort.SliceStable(orderedWarnings, func(left, right int) bool { return orderedWarnings[left].position < orderedWarnings[right].position })
warnings := make([]contracts.Warning, len(orderedWarnings))
for index, entry := range orderedWarnings {
warnings[index] = entry.warning
}
return output, warnings, len(application.RejectedGroups()), nil
}
func canConsolidate(items []dnd.Item) bool {
denomination := ""
hasCurrency := false
hasNonCurrency := false
hasConflictingDenominations := false
for _, item := range items {
current := currencyDenomination(item.Name)
if current == "" {
hasNonCurrency = true
continue
}
hasCurrency = true
if denomination != "" && denomination != current {
hasConflictingDenominations = true
continue
}
denomination = current
}
return !hasCurrency || (!hasNonCurrency && !hasConflictingDenominations)
}
func currencyDenomination(name string) string {
switch identity.ComparisonKey(name) {
case "copper piece", "copper pieces", "cp":
return "copper"
case "silver piece", "silver pieces", "sp":
return "silver"
case "electrum piece", "electrum pieces", "ep":
return "electrum"
case "gold piece", "gold pieces", "gp":
return "gold"
case "platinum piece", "platinum pieces", "pp":
return "platinum"
default:
return ""
}
}
func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning {
inputIndexes := provenance.OriginalInputIndexes()
details := make([]string, 0, len(inputIndexes)+1)
for _, inputIndex := range inputIndexes {
details = append(details, fmt.Sprintf("input index %d", inputIndex))
}
if canonical.earliest != provenance.EarliestInputPosition() {
details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest))
}
return contracts.Warning{
Scope: itemScope(provenance.EarliestInputPosition()),
ReasonCode: ReasonCodeDuplicateItemCollapsed,
Message: diagnostics.Aggregate("semantic duplicate consolidation", details),
}
}