Classify registry normalization diagnostics
This commit is contained in:
@@ -32,7 +32,6 @@ const (
|
||||
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
|
||||
ReasonCodeNPCSemanticProposalInvalid = "npc_semantic_proposal_invalid"
|
||||
ReasonCodeNPCSemanticReconciliationExhausted = "npc_semantic_reconciliation_exhausted"
|
||||
ReasonCodeNPCNormalizationWarningsOmitted = "npc_normalization_warnings_omitted"
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{"merged"}
|
||||
@@ -107,10 +106,10 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
||||
}
|
||||
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
records, warnings := preprocessRecords(req.MergeOutput.Value, order)
|
||||
records, findings := preprocessRecords(req.MergeOutput.Value, order)
|
||||
deterministic := recordList(records)
|
||||
if len(records) < 2 {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
|
||||
return normalizationResult(deterministic, findings, nil)
|
||||
}
|
||||
|
||||
candidates, envelopes, err := reconciliationInputs(records)
|
||||
@@ -127,53 +126,36 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
||||
|
||||
switch reconciliation.Disposition() {
|
||||
case semanticreconcile.SkippedInsufficientCandidates:
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
|
||||
return normalizationResult(deterministic, findings, nil)
|
||||
case semanticreconcile.SkippedLimitExceeded:
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: deterministic, Warnings: limitWarningsWithSemanticFallback(warnings)}, nil
|
||||
return fallbackResult(deterministic, findings, semanticFallbackFinding(-1))
|
||||
case semanticreconcile.RetryableInvalidStructuredOutput:
|
||||
return n.invalidStructuredResult(deterministic, warnings), nil
|
||||
return n.invalidStructuredResult(deterministic, findings)
|
||||
case semanticreconcile.Complete, semanticreconcile.RetryableDiscardedProposalGroups:
|
||||
default:
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("unknown semantic reconciliation disposition %d", reconciliation.Disposition())
|
||||
}
|
||||
|
||||
applied, semanticWarnings, err := applyReconciliationPlan(reconciliation.Plan(), records, envelopes, order)
|
||||
applied, semanticFindings, err := applyReconciliationPlan(reconciliation.Plan(), records, envelopes, order)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("apply semantic reconciliation plan: %w", err)
|
||||
}
|
||||
warnings = append(warnings, semanticWarnings...)
|
||||
findings = append(findings, semanticFindings...)
|
||||
if reconciliation.Disposition() == semanticreconcile.Complete {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: recordList(applied), Warnings: limitWarnings(warnings), ModelCandidate: reconciliation.ModelCandidate()}, nil
|
||||
return normalizationResult(recordList(applied), findings, reconciliation.ModelCandidate())
|
||||
}
|
||||
return retryResult(recordList(applied), warnings, reconciliation), nil
|
||||
return retryResult(recordList(applied), findings, reconciliation)
|
||||
}
|
||||
|
||||
func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.NPCRegistry] {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{
|
||||
Value: value,
|
||||
Warnings: limitWarningsForRetry(warnings),
|
||||
Retry: &contracts.NormalizeRetry{
|
||||
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
|
||||
Message: "semantic proposal requires retry: invalid structured output",
|
||||
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(-1)},
|
||||
},
|
||||
}
|
||||
func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, findings []contracts.Warning) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
|
||||
return retryResultWithFallback(value, findings, nil, "semantic proposal requires retry: invalid structured output", semanticFallbackFinding(-1))
|
||||
}
|
||||
|
||||
func retryResult(value dnd.NPCRegistry, warnings []contracts.Warning, reconciliation semanticreconcile.Result) contracts.TypedNormalizeResult[dnd.NPCRegistry] {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{
|
||||
Value: value,
|
||||
Warnings: limitWarningsForRetry(warnings),
|
||||
ModelCandidate: reconciliation.ModelCandidate(),
|
||||
Retry: &contracts.NormalizeRetry{
|
||||
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
|
||||
Message: diagnostics.Aggregate("semantic proposal requires retry", semanticreconcile.IssueDetails(reconciliation.Issues())),
|
||||
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(reconciliation.DiscardedGroupCount())},
|
||||
},
|
||||
}
|
||||
func retryResult(value dnd.NPCRegistry, findings []contracts.Warning, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
|
||||
return retryResultWithFallback(value, findings, reconciliation.ModelCandidate(), diagnostics.Aggregate("semantic proposal requires retry", semanticreconcile.IssueDetails(reconciliation.Issues())), semanticFallbackFinding(reconciliation.DiscardedGroupCount()))
|
||||
}
|
||||
|
||||
func semanticFallbackWarning(discardedGroups int) contracts.Warning {
|
||||
func semanticFallbackFinding(discardedGroups int) contracts.Warning {
|
||||
message := "semantic proposal could not be applied"
|
||||
if discardedGroups >= 0 {
|
||||
message = fmt.Sprintf("%d proposal group(s) omitted after semantic proposal retry exhaustion", discardedGroups)
|
||||
@@ -181,27 +163,38 @@ func semanticFallbackWarning(discardedGroups int) contracts.Warning {
|
||||
return contracts.Warning{Scope: "npcs", ReasonCode: ReasonCodeNPCSemanticReconciliationExhausted, Message: message}
|
||||
}
|
||||
|
||||
func limitWarnings(warnings []contracts.Warning) []contracts.Warning {
|
||||
return diagnostics.LimitWarnings(warnings, "npcs", ReasonCodeNPCNormalizationWarningsOmitted)
|
||||
func normalizationResult(value dnd.NPCRegistry, findings []contracts.Warning, candidate *contracts.ModelCandidate) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
|
||||
diagnosticGroups, err := diagnostics.Collect(findings, contracts.DiagnosticDispositionObservation, contracts.DiagnosticCategoryNormalization)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("collect normalization diagnostics: %w", err)
|
||||
}
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: value, Diagnostics: diagnosticGroups, ModelCandidate: candidate}, nil
|
||||
}
|
||||
|
||||
func limitWarningsForRetry(warnings []contracts.Warning) []contracts.Warning {
|
||||
if warnings == nil {
|
||||
return nil
|
||||
func fallbackResult(value dnd.NPCRegistry, findings []contracts.Warning, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
|
||||
result, err := normalizationResult(value, findings, nil)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, err
|
||||
}
|
||||
if len(warnings) < diagnostics.MaxWarnings {
|
||||
return append([]contracts.Warning(nil), warnings...)
|
||||
fallbackGroups, err := diagnostics.Collect([]contracts.Warning{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err)
|
||||
}
|
||||
displayed := diagnostics.MaxWarnings - 2
|
||||
bounded := append([]contracts.Warning(nil), warnings[:displayed]...)
|
||||
return append(bounded, contracts.Warning{
|
||||
Scope: "npcs", ReasonCode: ReasonCodeNPCNormalizationWarningsOmitted,
|
||||
Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed),
|
||||
})
|
||||
result.Diagnostics = append(result.Diagnostics, fallbackGroups...)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func limitWarningsWithSemanticFallback(warnings []contracts.Warning) []contracts.Warning {
|
||||
return append(limitWarningsForRetry(warnings), semanticFallbackWarning(-1))
|
||||
func retryResultWithFallback(value dnd.NPCRegistry, findings []contracts.Warning, candidate *contracts.ModelCandidate, message string, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) {
|
||||
result, err := normalizationResult(value, findings, candidate)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, err
|
||||
}
|
||||
fallbackGroups, err := diagnostics.Collect([]contracts.Warning{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback)
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err)
|
||||
}
|
||||
result.Retry = &contracts.NormalizeRetry{ReasonCode: ReasonCodeNPCSemanticProposalInvalid, Message: message, FallbackDiagnostics: fallbackGroups}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type normalizedRecord struct {
|
||||
|
||||
@@ -65,8 +65,8 @@ func TestNormalizeNamesEvidenceAndIDs(t *testing.T) {
|
||||
t.Fatalf("NPC = %#v, want %#v", result.Value.NPCs[0], want)
|
||||
}
|
||||
for _, reason := range []string{ReasonCodeNPCFieldsNormalized, ReasonCodeSourceReferencesNormalized, ReasonCodeNPCIDRecomputed} {
|
||||
if !hasWarning(result.Warnings, reason, "npcs[0]") {
|
||||
t.Fatalf("warnings = %#v, want %s", result.Warnings, reason)
|
||||
if !hasDiagnostic(result.Diagnostics, reason, "npcs[0]", contracts.DiagnosticDispositionObservation) {
|
||||
t.Fatalf("diagnostics = %#v, want %s", result.Diagnostics, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -104,8 +104,8 @@ func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T)
|
||||
if refs := result.Value.NPCs[0].SourceRefs; len(refs) != 2 || refs[0].SourceID != "a" || refs[1].SourceID != "b" {
|
||||
t.Fatalf("source refs = %#v, want evidence union", refs)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
|
||||
t.Fatalf("warnings = %#v, want duplicate collapse", result.Warnings)
|
||||
if !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateNPCCollapsed, "npcs[0]", contracts.DiagnosticDispositionObservation) {
|
||||
t.Fatalf("diagnostics = %#v, want duplicate collapse", result.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,10 +197,15 @@ func normalizeRequestWithSource(value dnd.NPCRegistry, doc *source.SourceDocumen
|
||||
return request
|
||||
}
|
||||
|
||||
func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
|
||||
for _, warning := range warnings {
|
||||
if warning.ReasonCode == reason && warning.Scope == scope {
|
||||
return true
|
||||
func hasDiagnostic(diagnostics []contracts.ProducerDiagnostic, reason, scope string, disposition contracts.DiagnosticDisposition) bool {
|
||||
for _, diagnostic := range diagnostics {
|
||||
if diagnostic.ReasonCode != reason || diagnostic.Disposition != disposition {
|
||||
continue
|
||||
}
|
||||
for _, sample := range diagnostic.Samples {
|
||||
if sample.Scope == scope {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/semanticreconcile"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
|
||||
func TestNormalizeSkipsSemanticCompletionWithoutTwoEligibleCandidates(t *testing.T) {
|
||||
@@ -67,8 +66,8 @@ func TestNormalizeAppliesSafeProposalAndUsesPrivateInputs(t *testing.T) {
|
||||
if merged.ID != identity.DeriveID("Mira Thorn") || !reflect.DeepEqual(merged.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}) {
|
||||
t.Fatalf("merged NPC = %#v, want canonical ID and original evidence union", merged)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeDuplicateNPCCollapsed, "npcs[0]") {
|
||||
t.Fatalf("warnings = %#v, want semantic collapse warning", result.Warnings)
|
||||
if !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateNPCCollapsed, "npcs[0]", contracts.DiagnosticDispositionObservation) {
|
||||
t.Fatalf("diagnostics = %#v, want semantic collapse observation", result.Diagnostics)
|
||||
}
|
||||
if len(client.requests) != 1 {
|
||||
t.Fatalf("completion calls = %d, want one", len(client.requests))
|
||||
@@ -119,8 +118,8 @@ func TestNormalizeUnsafeProposalReturnsSafeRetryFallback(t *testing.T) {
|
||||
if len(result.Value.NPCs) != 2 || result.Value.NPCs[0].Name != "Mira Thorn" || result.Value.NPCs[1].Name != "Captain Vale" {
|
||||
t.Fatalf("fallback NPCs = %#v, want independently safe group applied", result.Value.NPCs)
|
||||
}
|
||||
if len(result.Retry.FallbackWarnings) != 1 || result.Retry.FallbackWarnings[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackWarnings[0].Message, "1 proposal group") {
|
||||
t.Fatalf("fallback warnings = %#v, want exact omitted-group warning", result.Retry.FallbackWarnings)
|
||||
if len(result.Retry.FallbackDiagnostics) != 1 || result.Retry.FallbackDiagnostics[0].ReasonCode != ReasonCodeNPCSemanticReconciliationExhausted || !strings.Contains(result.Retry.FallbackDiagnostics[0].Samples[0].Message, "1 proposal group") {
|
||||
t.Fatalf("fallback diagnostics = %#v, want exact omitted-group warning", result.Retry.FallbackDiagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,19 +263,8 @@ func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testin
|
||||
if len(client.requests) != 0 || len(result.Value.NPCs) != limit+1 {
|
||||
t.Fatalf("completion calls = %d, NPCs = %d; want no call and all records", len(client.requests), len(result.Value.NPCs))
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeNPCSemanticReconciliationExhausted, "npcs") || len(result.Warnings) > diagnostics.MaxWarnings {
|
||||
t.Fatalf("warnings = %#v, want bounded reconciliation fallback", result.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryWarningLimitReservesExhaustionWarningPosition(t *testing.T) {
|
||||
warnings := make([]contracts.Warning, 0, 25)
|
||||
for index := 0; index < 25; index++ {
|
||||
warnings = append(warnings, contracts.Warning{Scope: "npcs", ReasonCode: "test", Message: "warning"})
|
||||
}
|
||||
bounded := limitWarningsForRetry(warnings)
|
||||
if len(bounded) != 19 || bounded[len(bounded)-1].ReasonCode != ReasonCodeNPCNormalizationWarningsOmitted || !strings.Contains(bounded[len(bounded)-1].Message, "7 additional") {
|
||||
t.Fatalf("retry warnings = %#v, want 18 warnings plus accurate omission summary", bounded)
|
||||
if !hasDiagnostic(result.Diagnostics, ReasonCodeNPCSemanticReconciliationExhausted, "npcs", contracts.DiagnosticDispositionWarning) {
|
||||
t.Fatalf("diagnostics = %#v, want reconciliation fallback warning", result.Diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user