Migrate item reconciliation to shared engine

This commit is contained in:
2026-08-09 16:46:43 +00:00
parent 8c071800cf
commit c7f79fb38e
7 changed files with 286 additions and 233 deletions

View File

@@ -1,2 +0,0 @@
Item candidates:
{{ input "candidates" }}

View File

@@ -1,8 +1,9 @@
Use candidate names and cited transcript windows only to determine whether Use the supplied positive integer candidate IDs and their cited transcript
candidates identify the same item type or unique designation. Do not treat windows only to determine whether candidates identify the same item type or
nearby evidence, similar objects, or a shared owner as sufficient. Keep unique designation. Do not treat nearby evidence, similar objects, or a shared
currency denominations, materially different item types, and uncertain aliases owner as sufficient. Keep currency denominations, materially different item
separate. Do not infer an item property or uniqueness. types, and uncertain aliases separate. Do not infer an item property or
uniqueness.
When selecting a canonical display name, choose one supplied candidate name When selecting a canonical display name, choose one supplied candidate name
that is the clearest established designation. that is the clearest established designation.

View File

@@ -12,19 +12,19 @@ messages:
- role: system - role: system
content_file: ./sharedassets/common-dnd-system.md content_file: ./sharedassets/common-dnd-system.md
- role: user - role: user
content_file: ./instructions.md content_file: ./sharedassets/protocol.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-entity-reconciliation.md content_file: ./instructions.md
cache_control: cache_control:
type: ephemeral type: ephemeral
- role: user - role: user
content_file: ./candidates.md content_file: ./sharedassets/candidates.md
- role: user - role: user
content_file: ./sharedassets/common-dnd-transcript-windows.md content_file: ./sharedassets/transcript-windows.md
cache_control: cache_control:
type: ephemeral type: ephemeral
output: output:
format: json format: json
validation_mode: json_schema validation_mode: json_schema
schema_path: dnd_entity_reconcile_llm.v1.json schema_path: semantic_reconciliation_llm.v1.json
repair_attempts: 0 repair_attempts: 0

View File

@@ -3,7 +3,6 @@ package itemregistry
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"sort" "sort"
@@ -13,20 +12,19 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
const ( const (
Key = "dnd/item-registry" Key = "dnd/item-registry"
PromptID = "dnd.item_registry.normalize" PromptID = "dnd.item_registry.normalize"
normalizationPolicy = "dnd.item_registry.normalize.v2" PromptVersion = "v1"
semanticContextPolicy = "dnd.entity_reconcile.context.v1" normalizationPolicy = "dnd.item_registry.normalize.v3"
semanticContextRadius = 2 NormalizationPolicy = normalizationPolicy
NormalizationPolicy = normalizationPolicy
ReasonCodeItemFieldsNormalized = "item_fields_normalized" ReasonCodeItemFieldsNormalized = "item_fields_normalized"
ReasonCodeItemIDRecomputed = "item_id_recomputed" ReasonCodeItemIDRecomputed = "item_id_recomputed"
@@ -47,9 +45,7 @@ var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
type Options struct{} type Options struct{}
type Normalizer struct { type Normalizer struct {
llm contracts.StructuredLLMClient engine *semanticreconcile.Engine
promptSHA string
responseSchemaSHA string
} }
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) { func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
@@ -60,46 +56,45 @@ func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error
if err != nil { if err != nil {
return nil, normalizerErrorf("load prompt metadata: %w", err) return nil, normalizerErrorf("load prompt metadata: %w", err)
} }
responseSchema, err := entityreconcile.LoadResponseSchema() engine, err := semanticreconcile.NewEngine(llmClient, semanticreconcile.PromptSpec{
ID: PromptID, Version: PromptVersion, SHA256: promptSHA,
}, semanticreconcile.DefaultLimits())
if err != nil { if err != nil {
return nil, normalizerErrorf("load response schema: %w", err) return nil, normalizerErrorf("construct semantic reconciliation engine: %w", err)
} }
return &Normalizer{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil return &Normalizer{engine: engine}, nil
} }
func (n *Normalizer) Key() string { return Key } func (n *Normalizer) Key() string { return Key }
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (n *Normalizer) ManifestMetadata() map[string]any { func (n *Normalizer) ManifestMetadata() map[string]any {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return map[string]any{ metadata := n.engine.ManifestMetadata()
"prompt_id": PromptID, "prompt_version": entityreconcile.SchemaVersion, "prompt_sha256": n.promptSHA, metadata["identity_policy"] = identity.Policy
"response_schema_key": string(entityreconcile.ResponseSchemaKey), "response_schema_id": entityreconcile.ResponseSchemaID, metadata["normalization_policy"] = normalizationPolicy
"response_schema_name": entityreconcile.ResponseSchemaName, "response_schema_version": entityreconcile.SchemaVersion, return metadata
"response_schema_sha256": n.responseSchemaSHA, "identity_policy": identity.Policy,
"normalization_policy": normalizationPolicy, "semantic_context_policy": semanticContextPolicy, "semantic_context_radius": semanticContextRadius,
}
} }
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint { func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
if n == nil { if n == nil || n.engine == nil {
return nil return nil
} }
return []pipeline.CheckpointFingerprint{ fingerprints := n.engine.CheckpointFingerprints()
{Name: "prompt", Value: n.promptSHA}, {Name: "response_schema", Value: n.responseSchemaSHA}, return append(fingerprints,
{Name: "identity_policy", Value: identity.Policy}, {Name: "normalization_policy", Value: normalizationPolicy}, pipeline.CheckpointFingerprint{Name: "identity_policy", Value: identity.Policy},
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)}, pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy},
} )
} }
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemRegistry]) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.ItemRegistry]) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) {
if n == nil { if n == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("normalizer must not be nil") return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("normalizer must not be nil")
} }
if n.llm == nil { if n.engine == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("LLM client must not be nil") return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("semantic reconciliation engine must not be nil")
} }
if ctx == nil { if ctx == nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context must not be nil") return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("context must not be nil")
@@ -111,35 +106,44 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
order := shared.NewSourceRefOrder(req.Source) order := shared.NewSourceRefOrder(req.Source)
records, warnings := preprocessRecords(req.MergeOutput.Value, order) records, warnings := preprocessRecords(req.MergeOutput.Value, order)
deterministic := recordList(records) deterministic := recordList(records)
materials, ready, err := entityreconcile.BuildContext(req.Source, reconciliationCandidates(records), semanticContextRadius) if len(records) < 2 || req.Source == nil {
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("build semantic context: %w", err)
}
if !ready {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
} }
var response entityreconcile.ProposalResponse candidates, envelopes, err := reconciliationInputs(records)
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ if err != nil {
StageName: Key, PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("prepare semantic reconciliation inputs: %w", err)
}
reconciliation, err := n.engine.Reconcile(ctx, semanticreconcile.Request{
StageName: Key, Source: req.Source, Candidates: candidates,
ProfileID: req.LLMProfile, SessionID: req.SessionID, ProfileID: req.LLMProfile, SessionID: req.SessionID,
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript}, })
}, &response); err != nil { if err != nil {
if errors.Is(err, contracts.ErrInvalidStructuredOutput) { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("reconcile semantic duplicates: %w", err)
return n.invalidStructuredResult(deterministic, warnings), nil }
}
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("complete structured output: %w", err) switch reconciliation.Disposition() {
case semanticreconcile.SkippedInsufficientCandidates:
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
case semanticreconcile.SkippedLimitExceeded:
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: deterministic, Warnings: limitWarningsWithSemanticFallback(warnings)}, nil
case semanticreconcile.RetryableInvalidStructuredOutput:
return n.invalidStructuredResult(deterministic, warnings), nil
case semanticreconcile.Complete, semanticreconcile.RetryableDiscardedProposalGroups:
default:
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("unknown semantic reconciliation disposition %d", reconciliation.Disposition())
}
applied, semanticWarnings, rejectedGroups, err := applyReconciliationPlan(reconciliation.Plan(), records, envelopes, order)
if err != nil {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("apply semantic reconciliation plan: %w", err)
} }
assessment := materials.Assess(response)
applied, semanticWarnings, rejectedGroups := applySafeGroups(records, reconciliationGroups(assessment, materials.CandidateKeys()), order)
warnings = append(warnings, semanticWarnings...) warnings = append(warnings, semanticWarnings...)
if rejectedGroups > 0 { discardedGroups := reconciliation.DiscardedGroupCount() + rejectedGroups
return currencyRetryResult(recordList(applied), warnings, rejectedGroups), nil if discardedGroups == 0 {
}
if assessment.DiscardedGroups() == 0 {
return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil
} }
return retryResult(recordList(applied), warnings, assessment), nil return retryResult(recordList(applied), warnings, reconciliation, rejectedGroups), nil
} }
func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.ItemRegistry] { func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
@@ -149,17 +153,15 @@ func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, warnings []
}} }}
} }
func retryResult(value dnd.ItemRegistry, warnings []contracts.Warning, assessment entityreconcile.Assessment) contracts.TypedNormalizeResult[dnd.ItemRegistry] { func retryResult(value dnd.ItemRegistry, warnings []contracts.Warning, reconciliation semanticreconcile.Result, rejectedGroups int) contracts.TypedNormalizeResult[dnd.ItemRegistry] {
details := reconciliationIssues(reconciliation.Issues())
if rejectedGroups > 0 {
details = append(details, "currency may only be consolidated with aliases of one denomination")
}
discardedGroups := reconciliation.DiscardedGroupCount() + rejectedGroups
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: diagnostics.Aggregate("semantic proposal requires retry", reconciliationIssues(assessment)), ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: diagnostics.Aggregate("semantic proposal requires retry", details),
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.DiscardedGroups())}, FallbackWarnings: []contracts.Warning{semanticFallbackWarning(discardedGroups)},
}}
}
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 may only be consolidated with aliases of one denomination",
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(rejectedGroups)},
}} }}
} }
@@ -187,6 +189,10 @@ func limitWarningsForRetry(warnings []contracts.Warning) []contracts.Warning {
return append(bounded, contracts.Warning{Scope: "items", ReasonCode: ReasonCodeItemNormalizationWarningsOmitted, Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed)}) return append(bounded, contracts.Warning{Scope: "items", ReasonCode: ReasonCodeItemNormalizationWarningsOmitted, Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed)})
} }
func limitWarningsWithSemanticFallback(warnings []contracts.Warning) []contracts.Warning {
return append(limitWarningsForRetry(warnings), semanticFallbackWarning(-1))
}
type normalizedRecord struct { type normalizedRecord struct {
item dnd.Item item dnd.Item
inputIndexes []int inputIndexes []int

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"reflect" "reflect"
"strconv" "strconv"
"strings" "strings"
@@ -14,10 +15,10 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "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/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
identityvalidator "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/identity" identityvalidator "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/itemregistry/identity"
"gitea.maximumdirect.net/eric/promptkit" "gitea.maximumdirect.net/eric/promptkit"
) )
@@ -35,9 +36,15 @@ func TestModuleContractAndMetadata(t *testing.T) {
t.Fatal("New() accepted nil client") t.Fatal("New() accepted nil client")
} }
metadata := newNormalizer(t, &recordingNormalizerClient{}).ManifestMetadata() metadata := newNormalizer(t, &recordingNormalizerClient{}).ManifestMetadata()
if metadata["identity_policy"] != identity.Policy || metadata["response_schema_id"] != entityreconcile.ResponseSchemaID || metadata["normalization_policy"] != normalizationPolicy || metadata["semantic_context_radius"] != semanticContextRadius { limits, ok := metadata["semantic_reconciliation_limits"].(map[string]any)
if !ok || metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy || metadata["prompt_id"] != PromptID || metadata["prompt_version"] != PromptVersion || metadata["response_schema_key"] != string(semanticreconcile.ResponseSchemaKey) || metadata["response_schema_id"] != semanticreconcile.ResponseSchemaID || metadata["response_schema_name"] != semanticreconcile.ResponseSchemaName || metadata["semantic_reconciliation_policy"] != semanticreconcile.Policy || len(limits) != 3 {
t.Fatalf("metadata = %#v", metadata) t.Fatalf("metadata = %#v", metadata)
} }
for _, name := range []string{"prompt", "response_schema", "semantic_reconciliation_policy", "semantic_reconciliation_limits", "identity_policy", "normalization_policy"} {
if !hasFingerprint(newNormalizer(t, &recordingNormalizerClient{}).CheckpointFingerprints(), name) {
t.Fatalf("fingerprints missing %q", name)
}
}
} }
func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing.T) { func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing.T) {
@@ -73,10 +80,10 @@ func TestNormalizeConsolidatesEqualNamesAcrossEvidenceWithoutMutation(t *testing
} }
var candidates struct { var candidates struct {
Candidates []struct { Candidates []struct {
Name string `json:"name"` Label string `json:"label"`
} `json:"candidates"` } `json:"candidates"`
} }
if err := json.Unmarshal(client.requests[0].Inputs["candidates"].Content, &candidates); err != nil || len(candidates.Candidates) != 2 || candidates.Candidates[0].Name != "Rope" || candidates.Candidates[1].Name != "Lantern" { if err := json.Unmarshal(client.requests[0].Inputs["candidates"].Content, &candidates); err != nil || len(candidates.Candidates) != 2 || candidates.Candidates[0].Label != "Rope" || candidates.Candidates[1].Label != "Lantern" {
t.Fatalf("semantic candidates = %#v, %v; want one candidate per comparison name", candidates, err) t.Fatalf("semantic candidates = %#v, %v; want one candidate per comparison name", candidates, err)
} }
repeated, repeatErr := newNormalizer(t, &recordingNormalizerClient{}).Normalize(context.Background(), normalizeRequestWithSource(result.Value, doc)) repeated, repeatErr := newNormalizer(t, &recordingNormalizerClient{}).Normalize(context.Background(), normalizeRequestWithSource(result.Value, doc))
@@ -106,7 +113,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
{Name: "Compass of the Stars", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Compass of the Stars", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, {Name: "Gold Pieces", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}} }}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`} client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc)) result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil || len(result.Value.Items) != 2 { if err != nil || result.Retry != nil || len(result.Value.Items) != 2 {
t.Fatalf("Normalize() = %#v, %v", result, err) t.Fatalf("Normalize() = %#v, %v", result, err)
@@ -142,7 +149,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Piece", SourceRefs: ref(20)}, {Name: "Gold Piece", SourceRefs: ref(20)},
{Name: "Gold Pieces", SourceRefs: ref(30)}, {Name: "Gold Pieces", SourceRefs: ref(30)},
}, },
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002","candidate-000003"],"canonical":"candidate-000002"}]}`, response: `{"duplicate_groups":[{"candidate_ids":[1,2,3],"canonical_candidate_id":2}]}`,
wantNames: []string{"Gold Piece"}, wantNames: []string{"Gold Piece"},
wantRefCounts: []int{3}, wantRefCounts: []int{3},
warning: ReasonCodeDuplicateItemCollapsed, warning: ReasonCodeDuplicateItemCollapsed,
@@ -153,7 +160,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Silver Pieces", SourceRefs: ref(20)}, {Name: "Silver Pieces", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`, response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`,
wantNames: []string{"Gold Pieces", "Silver Pieces"}, wantNames: []string{"Gold Pieces", "Silver Pieces"},
wantRefCounts: []int{1, 1}, wantRefCounts: []int{1, 1},
wantRetry: true, wantRetry: true,
@@ -165,7 +172,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)}, {Name: "Longsword", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`, response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1}]}`,
wantNames: []string{"Gold Pieces", "Longsword"}, wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1}, wantRefCounts: []int{1, 1},
wantRetry: true, wantRetry: true,
@@ -177,7 +184,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Star Compass", SourceRefs: ref(10)}, {Name: "Star Compass", SourceRefs: ref(10)},
{Name: "Compass of the Stars", SourceRefs: ref(20)}, {Name: "Compass of the Stars", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`, response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`,
wantNames: []string{"Compass of the Stars"}, wantNames: []string{"Compass of the Stars"},
wantRefCounts: []int{2}, wantRefCounts: []int{2},
warning: ReasonCodeDuplicateItemCollapsed, warning: ReasonCodeDuplicateItemCollapsed,
@@ -188,7 +195,7 @@ func TestNormalizeAppliesCurrencyReconciliationSafely(t *testing.T) {
{Name: "Gold Pieces", SourceRefs: ref(10)}, {Name: "Gold Pieces", SourceRefs: ref(10)},
{Name: "Longsword", SourceRefs: ref(20)}, {Name: "Longsword", SourceRefs: ref(20)},
}, },
response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`, response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2}]}`,
wantNames: []string{"Gold Pieces", "Longsword"}, wantNames: []string{"Gold Pieces", "Longsword"},
wantRefCounts: []int{1, 1}, wantRefCounts: []int{1, 1},
wantRetry: true, wantRetry: true,
@@ -231,13 +238,67 @@ func TestNormalizePreservesCandidatesForUnsafeProposalGroups(t *testing.T) {
{Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}},
{Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}}, {Name: "Rope", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
}} }}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"},{"members":["candidate-000002","candidate-000003"],"canonical":"candidate-000003"},{"members":["candidate-000001","candidate-000003"],"canonical":"candidate-000099"}]}`} client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":1},{"candidate_ids":[2,3],"canonical_candidate_id":3},{"candidate_ids":[1,3],"canonical_candidate_id":99}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc)) result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil || len(result.Value.Items) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") || !strings.Contains(result.Retry.Message, "canonical_unknown") || strings.Contains(result.Retry.Message, "Star Compass") || len(result.Retry.Message) > 4096 { if err != nil || result.Retry == nil || len(result.Value.Items) != 3 || !strings.Contains(result.Retry.Message, "overlapping_member") || !strings.Contains(result.Retry.Message, "canonical_unknown") || strings.Contains(result.Retry.Message, "Star Compass") || len(result.Retry.Message) > 4096 {
t.Fatalf("Normalize() = %#v, %v; want deterministic retry fallback", result, err) t.Fatalf("Normalize() = %#v, %v; want deterministic retry fallback", result, err)
} }
} }
func TestNormalizeAppliesIndependentGroupAndCountsAllOmissions(t *testing.T) {
doc := semanticDocument()
ref := func(unitID int) []source.SourceRef {
return []source.SourceRef{{SourceID: doc.ID, StartUnitID: unitID, EndUnitID: unitID}}
}
input := dnd.ItemRegistry{Items: []dnd.Item{
{Name: "Star Compass", SourceRefs: ref(10)},
{Name: "Compass of the Stars", SourceRefs: ref(20)},
{Name: "Gold Pieces", SourceRefs: ref(30)},
{Name: "Silver Pieces", SourceRefs: ref(30)},
{Name: "Rope", SourceRefs: ref(10)},
}}
client := &recordingNormalizerClient{response: `{"duplicate_groups":[{"candidate_ids":[1,2],"canonical_candidate_id":2},{"candidate_ids":[3,4],"canonical_candidate_id":3},{"candidate_ids":[5,99],"canonical_candidate_id":5}]}`}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry == nil {
t.Fatalf("Normalize() = %#v, %v; want retry with independently accepted output", result, err)
}
wantNames := []string{"Compass of the Stars", "Gold Pieces", "Silver Pieces", "Rope"}
if len(result.Value.Items) != len(wantNames) {
t.Fatalf("items = %#v, want %v", result.Value.Items, wantNames)
}
for index, name := range wantNames {
if result.Value.Items[index].Name != name {
t.Fatalf("item %d = %#v, want %q", index, result.Value.Items[index], name)
}
}
if !hasWarning(result.Warnings, ReasonCodeDuplicateItemCollapsed) || !hasWarning(result.Warnings, ReasonCodeItemSemanticProposalInvalid) {
t.Fatalf("warnings = %#v, want accepted and guarded-group diagnostics", result.Warnings)
}
if len(result.Retry.FallbackWarnings) != 1 || !strings.Contains(result.Retry.FallbackWarnings[0].Message, "2 proposal group(s)") {
t.Fatalf("retry = %#v, want one guarded and one malformed group counted", result.Retry)
}
}
func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testing.T) {
client := &recordingNormalizerClient{}
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Kind: "narration", Text: "A crowded storeroom"}}}
limit := semanticreconcile.DefaultLimits().MaximumCandidates
input := dnd.ItemRegistry{Items: make([]dnd.Item, limit+1)}
for index := range input.Items {
input.Items[index] = dnd.Item{Name: fmt.Sprintf("Item %d", index), SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}}
}
result, err := newNormalizer(t, client).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
if err != nil || result.Retry != nil {
t.Fatalf("Normalize() = %#v, %v; want deterministic limit fallback", result, err)
}
if len(client.requests) != 0 || len(result.Value.Items) != limit+1 {
t.Fatalf("completion calls = %d, items = %d; want no call and all records", len(client.requests), len(result.Value.Items))
}
if !hasWarning(result.Warnings, ReasonCodeItemSemanticReconciliationExhausted) || len(result.Warnings) > diagnostics.MaxWarnings {
t.Fatalf("warnings = %#v, want bounded reconciliation fallback", result.Warnings)
}
}
func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) { func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) {
doc := semanticDocument() doc := semanticDocument()
input := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}} input := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}}
@@ -261,7 +322,7 @@ func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) {
func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) { func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
registry := llm.NewAssetRegistry() registry := llm.NewAssetRegistry()
if err := entityreconcile.RegisterSchemaAssets(registry); err != nil { if err := semanticreconcile.RegisterAssets(registry); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := RegisterPromptAssets(registry); err != nil { if err := RegisterPromptAssets(registry); err != nil {
@@ -276,13 +337,16 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}}) prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: PromptVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"candidate_id":1,"label":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if prepared.OutputContract.SchemaPath != "dnd_entity_reconcile_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "currency denominations") || !strings.Contains(prepared.Messages[1].Content, "materially different item types") { if prepared.OutputContract.SchemaPath != "semantic_reconciliation_llm.v1.json" || !strings.Contains(prepared.Messages[1].Content, "candidate_id") || !strings.Contains(prepared.Messages[1].Content, "integer") || !strings.Contains(prepared.Messages[2].Content, "currency denominations") || !strings.Contains(prepared.Messages[2].Content, "materially different item") || prepared.Messages[2].CacheControl == nil || prepared.Messages[2].CacheControl.Type != promptkit.CacheControlEphemeral {
t.Fatalf("prepared prompt = %#v", prepared) t.Fatalf("prepared prompt = %#v", prepared)
} }
if prepared.Messages[4].CacheControl == nil || prepared.Messages[4].CacheControl.Type != promptkit.CacheControlEphemeral || !strings.Contains(prepared.Messages[3].Content, `"Rope"`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) || !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"Rope"`) {
t.Fatalf("prepared prompt = %#v, want isolated candidate and transcript presentation", prepared)
}
} }
type recordingNormalizerClient struct { type recordingNormalizerClient struct {
@@ -300,53 +364,13 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques
if response == "" { if response == "" {
response = `{"duplicate_groups":[]}` response = `{"duplicate_groups":[]}`
} }
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content) content := []byte(response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, output); err != nil { if err := json.Unmarshal(content, output); err != nil {
return contracts.StructuredCompletionResponse{}, err return contracts.StructuredCompletionResponse{}, err
} }
return contracts.StructuredCompletionResponse{Content: content}, nil return contracts.StructuredCompletionResponse{Content: content}, nil
} }
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
if !strings.Contains(response, "candidate-") {
return []byte(response), nil
}
var selection struct {
DuplicateGroups []struct {
Members []string `json:"members"`
Canonical string `json:"canonical"`
} `json:"duplicate_groups"`
}
if err := json.Unmarshal([]byte(response), &selection); err != nil {
return nil, err
}
var candidates struct {
Candidates []entityreconcile.Selector `json:"candidates"`
}
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
return nil, err
}
selector := func(key string) entityreconcile.Selector {
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
if err != nil || index < 1 || index > len(candidates.Candidates) {
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
}
return candidates.Candidates[index-1].Clone()
}
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
for index, group := range selection.DuplicateGroups {
members := make([]entityreconcile.Selector, len(group.Members))
for memberIndex, key := range group.Members {
members[memberIndex] = selector(key)
}
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
}
return json.Marshal(proposal)
}
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer { func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
t.Helper() t.Helper()
normalizer, err := New(client, Options{}) normalizer, err := New(client, Options{})
@@ -374,3 +398,12 @@ func hasWarning(warnings []contracts.Warning, reason string) bool {
} }
return false return false
} }
func hasFingerprint(fingerprints []pipeline.CheckpointFingerprint, name string) bool {
for _, fingerprint := range fingerprints {
if fingerprint.Name == name && fingerprint.Value != "" {
return true
}
}
return false
}

View File

@@ -8,19 +8,26 @@ import (
rootassets "gitea.maximumdirect.net/eric/notarius/assets" rootassets "gitea.maximumdirect.net/eric/notarius/assets"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const promptAssetRoot = "assets/prompts" const promptAssetRoot = "assets/prompts"
var promptAssetManifest = shared.PromptAssetManifest{ func promptAssetManifest() (shared.PromptAssetManifest, error) {
ModuleDir: PromptID, sharedFiles, err := semanticreconcile.SharedPromptFiles()
ModuleFiles: []promptfs.ModulePromptFile{ if err != nil {
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"}, return shared.PromptAssetManifest{}, fmt.Errorf("load shared semantic reconciliation prompt assets: %w", err)
{Name: "instructions.md", Path: "prompts/instructions.md"}, }
{Name: "candidates.md", Path: "prompts/candidates.md"}, return shared.PromptAssetManifest{
}, ModuleDir: PromptID,
SharedFiles: []string{"common-dnd-system.md", "common-dnd-entity-reconciliation.md", "common-dnd-transcript-windows.md"}, ModuleFiles: []promptfs.ModulePromptFile{
{Name: "prompt.yaml", Path: "prompts/prompt.yaml"},
{Name: "instructions.md", Path: "prompts/instructions.md"},
},
SharedFiles: []string{"common-dnd-system.md"},
ExternalSharedFiles: sharedFiles,
}, nil
} }
func moduleAssetFS() (fs.FS, error) { func moduleAssetFS() (fs.FS, error) {
@@ -36,7 +43,11 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
if err != nil { if err != nil {
return err return err
} }
promptFS, err := promptAssetManifest.PromptFS(assets) manifest, err := promptAssetManifest()
if err != nil {
return err
}
promptFS, err := manifest.PromptFS(assets)
if err != nil { if err != nil {
return fmt.Errorf("prepare item normalization prompt assets: %w", err) return fmt.Errorf("prepare item normalization prompt assets: %w", err)
} }
@@ -50,7 +61,12 @@ func promptAssetMetadata() (string, error) {
promptAssetHashErr = err promptAssetHashErr = err
return return
} }
promptAssetHash, promptAssetHashErr = promptAssetManifest.Hash(assets) manifest, err := promptAssetManifest()
if err != nil {
promptAssetHashErr = err
return
}
promptAssetHash, promptAssetHashErr = manifest.Hash(assets)
}) })
return promptAssetHash, promptAssetHashErr return promptAssetHash, promptAssetHashErr
} }

View File

@@ -2,104 +2,106 @@ package itemregistry
import ( import (
"fmt" "fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "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/items/identity"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
) )
type safeReconciliationGroup struct { const incompatibleCurrencyGroup semanticreconcile.RejectionCategory = "incompatible_currency"
members []int
canonical int
}
func reconciliationCandidates(records []normalizedRecord) []entityreconcile.Candidate { func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candidate, []semanticreconcile.Record[dnd.Item], error) {
candidates := make([]entityreconcile.Candidate, len(records)) candidates := make([]semanticreconcile.Candidate, len(records))
envelopes := make([]semanticreconcile.Record[dnd.Item], len(records))
for index, record := range records { for index, record := range records {
candidates[index] = entityreconcile.Candidate{Name: record.item.Name, SourceRefs: cloneSourceRefs(record.item.SourceRefs)} 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 return candidates, envelopes, nil
} }
func reconciliationGroups(assessment entityreconcile.Assessment, candidateKeys []string) []safeReconciliationGroup { func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Item], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, int, error) {
positions := make(map[string]int, len(candidateKeys)) application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Item]{
for index, key := range candidateKeys { CloneValue: cloneItem,
positions[key] = index RejectGroup: func(members []dnd.Item, _ dnd.Item) semanticreconcile.RejectionCategory {
} if !canConsolidate(members) {
safeGroups := assessment.SafeGroups() return incompatibleCurrencyGroup
groups := make([]safeReconciliationGroup, 0, len(safeGroups))
for _, group := range safeGroups {
members := group.Members()
memberPositions := make([]int, len(members))
valid := true
for index, key := range members {
position, ok := positions[key]
if !ok {
valid = false
break
} }
memberPositions[index] = position return ""
} },
canonical, ok := positions[group.Canonical()] ConsolidateGroup: func(members []dnd.Item, canonical dnd.Item) (dnd.Item, error) {
if valid && ok { output := cloneItem(canonical)
groups = append(groups, safeReconciliationGroup{members: memberPositions, canonical: canonical}) output.SourceRefs = nil
} for _, member := range members {
} output.SourceRefs = append(output.SourceRefs, member.SourceRefs...)
return groups
}
func reconciliationIssues(assessment entityreconcile.Assessment) []string {
issues := assessment.Issues()
details := make([]string, len(issues))
for index, issue := range issues {
details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
}
return details
}
func applySafeGroups(records []normalizedRecord, groups []safeReconciliationGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, int) {
byMember := make(map[int]safeReconciliationGroup, len(groups)*2)
for _, group := range groups {
for _, member := range group.members {
byMember[member] = group
}
}
output := make([]normalizedRecord, 0, len(records)-len(groups))
warnings := make([]contracts.Warning, 0, len(groups))
rejectedGroups := 0
for index, record := range records {
group, grouped := byMember[index]
if !grouped {
output = append(output, cloneRecord(record))
continue
}
if group.members[0] != index {
continue
}
if !canConsolidate(records, group) {
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 may only be consolidated with aliases of one denomination"}) output.SourceRefs = order.Canonicalize(output.SourceRefs)
rejectedGroups++ output.ID = identity.DeriveID(output.Name)
continue return output, nil
} },
consolidated := consolidateSemanticGroup(records, group, order) })
output = append(output, consolidated) if err != nil {
warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical])) return nil, nil, 0, err
} }
return output, warnings, rejectedGroups
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(records []normalizedRecord, group safeReconciliationGroup) bool { func canConsolidate(items []dnd.Item) bool {
denomination := "" denomination := ""
hasCurrency := false hasCurrency := false
hasNonCurrency := false hasNonCurrency := false
hasConflictingDenominations := false hasConflictingDenominations := false
for _, member := range group.members { for _, item := range items {
current := currencyDenomination(records[member].item.Name) current := currencyDenomination(item.Name)
if current == "" { if current == "" {
hasNonCurrency = true hasNonCurrency = true
continue continue
@@ -131,29 +133,26 @@ func currencyDenomination(name string) string {
} }
} }
func consolidateSemanticGroup(records []normalizedRecord, group safeReconciliationGroup, order shared.SourceRefOrder) normalizedRecord { func reconciliationIssues(issues []semanticreconcile.Issue) []string {
output := cloneRecord(records[group.members[0]]) details := make([]string, len(issues))
output.item.Name = records[group.canonical].item.Name for index, issue := range issues {
for _, member := range group.members[1:] { details[index] = fmt.Sprintf("group %d: %s", issue.GroupIndex, issue.Category)
output.item.SourceRefs = append(output.item.SourceRefs, records[member].item.SourceRefs...)
output.inputIndexes = append(output.inputIndexes, records[member].inputIndexes...)
if records[member].earliest < output.earliest {
output.earliest = records[member].earliest
}
} }
output.inputIndexes = sortedUniqueIndexes(output.inputIndexes) return details
output.item.SourceRefs = order.Canonicalize(output.item.SourceRefs)
output.item.ID = identity.DeriveID(output.item.Name)
return output
} }
func semanticDuplicateWarning(record normalizedRecord, canonical normalizedRecord) contracts.Warning { func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning {
details := make([]string, 0, len(record.inputIndexes)+1) inputIndexes := provenance.OriginalInputIndexes()
for _, inputIndex := range record.inputIndexes { details := make([]string, 0, len(inputIndexes)+1)
for _, inputIndex := range inputIndexes {
details = append(details, fmt.Sprintf("input index %d", inputIndex)) details = append(details, fmt.Sprintf("input index %d", inputIndex))
} }
if canonical.earliest != record.earliest { if canonical.earliest != provenance.EarliestInputPosition() {
details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest)) details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest))
} }
return contracts.Warning{Scope: itemScope(record.earliest), ReasonCode: ReasonCodeDuplicateItemCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details)} return contracts.Warning{
Scope: itemScope(provenance.EarliestInputPosition()),
ReasonCode: ReasonCodeDuplicateItemCollapsed,
Message: diagnostics.Aggregate("semantic duplicate consolidation", details),
}
} }