From 4dbbf680513e317d1ae56fa2d360e13269a8441a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Thu, 27 Aug 2026 16:40:17 +0000 Subject: [PATCH] Finalize structured diagnostic aggregation --- docs/roadmap/implementation.md | 2 +- .../assembled_spell_pipeline_contract_test.go | 38 +++++------ .../dnd_scene_descriptions_contract_test.go | 4 +- internal/cli/example_contract_test.go | 6 +- internal/cli/production_contract_test.go | 6 +- internal/cli/run.go | 10 +-- internal/cli/run_contract_test.go | 10 +-- internal/cli/run_result.go | 13 +++- internal/cli/run_result_command_test.go | 2 +- internal/cli/run_result_test.go | 6 +- internal/cli/run_terminal.go | 2 +- .../spell_catalog_identity_contract_test.go | 2 +- .../cli/spell_catalog_retry_contract_test.go | 8 +-- internal/cli/state_hardening_test.go | 32 ++++++---- internal/core/debugbundle/bundle_test.go | 2 +- internal/core/debugbundle/summary.go | 2 +- .../framework/checkpoint/filesystem_test.go | 45 ++++++------- internal/framework/checkpoint/loader.go | 8 +-- internal/framework/checkpoint/recorder.go | 60 +++++++---------- .../framework/checkpoint/recorder_test.go | 6 +- internal/framework/chunkplan/store_test.go | 1 - internal/framework/contracts/contracts.go | 9 --- .../framework/contracts/typed_pipeline.go | 4 -- internal/framework/pipeline/checkpoint.go | 43 ++++++------- .../framework/pipeline/chunk_plan_store.go | 1 - internal/framework/pipeline/debug.go | 23 ++----- internal/framework/pipeline/diagnostics.go | 14 ---- .../framework/pipeline/diagnostics_test.go | 7 +- .../framework/pipeline/extractor_registry.go | 2 +- .../framework/pipeline/merger_registry.go | 2 +- .../framework/pipeline/normalizer_registry.go | 3 +- .../pipeline/normalizer_registry_test.go | 19 ++---- .../framework/pipeline/producer_attempts.go | 21 ++---- .../pipeline/producer_attempts_test.go | 24 +++---- internal/framework/pipeline/references.go | 64 ++++++------------- .../framework/pipeline/references_test.go | 20 +++--- internal/framework/pipeline/runner.go | 21 ++---- .../runner_accepted_checkpoint_test.go | 14 ++-- .../pipeline/runner_attempt_debug_test.go | 16 ++--- .../runner_candidate_encoding_test.go | 4 +- .../framework/pipeline/runner_chunk_plan.go | 30 +++------ .../pipeline/runner_chunk_plan_test.go | 38 +++++------ .../pipeline/runner_concurrency_test.go | 16 ++--- .../framework/pipeline/runner_concurrent.go | 29 +++------ .../runner_extract_correction_test.go | 14 +--- .../pipeline/runner_extract_handoff_test.go | 18 ++---- .../pipeline/runner_normalize_retry_test.go | 32 +++++----- .../runner_rejection_warnings_test.go | 55 ++++++++-------- .../pipeline/runner_reuse_lineage_test.go | 6 +- .../runner_semantic_correction_test.go | 4 +- .../pipeline/runner_terminal_debug_test.go | 10 +-- internal/framework/pipeline/runner_typed.go | 54 ++++++---------- .../pipeline/synchronized_collaborators.go | 30 ++++----- .../framework/pipeline/typed_execution.go | 1 - .../framework/pipeline/validation_executor.go | 16 +---- .../pipeline/validation_executor_test.go | 54 +++++++++------- .../modules/dnd/chunk/scenes/chunker_test.go | 3 - .../dnd/extract/combatturns/extractor_test.go | 4 +- .../dnd/extract/enemyevents/extractor_test.go | 4 +- .../dnd/extract/spells/extractor_test.go | 4 -- .../dnd/normalize/combatturns/normalizer.go | 18 +++--- .../normalize/combatturns/normalizer_test.go | 5 +- .../dnd/normalize/enemyevents/normalizer.go | 16 ++--- .../normalize/enemyevents/normalizer_test.go | 5 +- .../normalize/itemoccurrences/normalizer.go | 22 +++---- .../dnd/normalize/itemregistry/normalizer.go | 32 +++++----- .../normalize/itemregistry/reconciliation.go | 15 ++--- .../locationoccurrences/normalizer.go | 20 +++--- .../locationoccurrences/normalizer_test.go | 5 +- .../normalize/locationregistry/normalizer.go | 32 +++++----- .../locationregistry/reconciliation.go | 9 ++- .../normalize/npcoccurrences/normalizer.go | 16 ++--- .../npcoccurrences/normalizer_test.go | 5 +- .../dnd/normalize/npcregistry/normalizer.go | 34 +++++----- .../normalize/npcregistry/reconciliation.go | 9 ++- .../normalize/scenedescriptions/normalizer.go | 10 +-- .../scenedescriptions/normalizer_test.go | 7 +- .../dnd/normalize/spells/normalizer.go | 18 +++--- .../dnd/normalize/spells/normalizer_test.go | 25 +++----- .../dnd/shared/diagnostics/diagnostics.go | 43 ++++--------- .../shared/diagnostics/diagnostics_test.go | 33 ++-------- .../combatturns/invariants/validator_test.go | 2 +- .../combatturns/shape/validator_test.go | 2 +- .../combatturns/source_refs/validator_test.go | 2 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../dnd/validate/correction_contract_test.go | 4 -- .../enemyevents/shape/validator_test.go | 2 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../npcregistry/source_refs/validator_test.go | 2 +- .../source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 2 +- .../source_relatedness/validator.go | 6 +- .../source_relatedness/validator_test.go | 2 +- .../spells/source_relatedness/validator.go | 4 +- .../source_relatedness/validator_test.go | 4 +- .../modules/generic/output/json/encoder.go | 15 +++-- .../generic/output/json/encoder_test.go | 27 +++++--- .../integration/dnd_combat_runner_test.go | 14 ++-- .../integration/dnd_npc_grounded_test.go | 4 +- .../dnd_npc_registry_runner_test.go | 20 +++--- 112 files changed, 656 insertions(+), 893 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index b8e76f37..5f22cfd0 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -481,7 +481,7 @@ observations can be present while the warning group count remains zero. This stage is appropriately sized for one high-reasoning `gpt-5.6-terra` prompt. -## Stage 8 — Remove The Legacy Warning Path And Finalize Aggregation +## Stage 8 ✅ — Remove The Legacy Warning Path And Finalize Aggregation ### Goal diff --git a/internal/cli/assembled_spell_pipeline_contract_test.go b/internal/cli/assembled_spell_pipeline_contract_test.go index 608a4537..49e4dffe 100644 --- a/internal/cli/assembled_spell_pipeline_contract_test.go +++ b/internal/cli/assembled_spell_pipeline_contract_test.go @@ -77,22 +77,22 @@ func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) { spellnormalize.ReasonCodeDuplicateSpellCastCollapsed, "spell_not_near_source", } - gotWarningReasons := make([]string, len(output.Warnings)) - for index, warning := range output.Warnings { - gotWarningReasons[index] = warning.ReasonCode + gotWarningReasons := make([]string, len(output.Diagnostics.Groups)) + for index, group := range output.Diagnostics.Groups { + gotWarningReasons[index] = group.ReasonCode } if !reflect.DeepEqual(gotWarningReasons, wantWarningReasons) { - t.Fatalf("warnings = %#v, want deterministic normalize and validation warnings", output.Warnings) + t.Fatalf("diagnostics = %#v, want deterministic normalize and validation diagnostics", output.Diagnostics) } - if output.Warnings[2].Scope != "spell_casts[0]" || !strings.Contains(output.Warnings[2].Message, "retained input index 0") || !strings.Contains(output.Warnings[2].Message, "removed input indices [1]") { - t.Fatalf("duplicate warning = %#v, want retained and removed merged indices", output.Warnings[2]) + if output.Diagnostics.Groups[2].Samples[0].Scope != "spell_casts[0]" || !strings.Contains(output.Diagnostics.Groups[2].Samples[0].Message, "retained input index 0") || !strings.Contains(output.Diagnostics.Groups[2].Samples[0].Message, "removed input indices [1]") { + t.Fatalf("duplicate diagnostic = %#v, want retained and removed merged indices", output.Diagnostics.Groups[2]) } warningsFile := decodeAssembledOutput[struct { - Warnings []contracts.Warning `json:"warnings"` + Groups []contracts.DiagnosticGroup `json:"groups"` }](t, output.OutputFiles, "warnings.json") - if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) { - t.Fatalf("warnings file = %#v, run warnings = %#v, want manifest output path to preserve warnings", warningsFile.Warnings, output.Warnings) + if len(warningsFile.Groups) != 0 { + t.Fatalf("warnings file = %#v, want no process warnings for advisory-only diagnostics", warningsFile.Groups) } manifest := decodeAssembledOutput[artifacts.RunManifest](t, output.OutputFiles, "manifest.json") if len(manifest.ArtifactLanes) != 1 || manifest.ArtifactLanes[0].Normalizer != spellnormalize.Key { @@ -155,9 +155,9 @@ func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) { if err != nil || output.Manifest.ValidationStatus != "approved" || len(output.Rejected) != 0 || len(output.NormalizeOutputs) != 1 { t.Fatalf("Run() error = %v output = %#v, want approved override run", err, output) } - for _, warning := range output.Warnings { - if warning.ReasonCode == "spell_not_near_source" { - t.Fatalf("warnings = %#v, want explicit validator override to replace default relatedness chain", output.Warnings) + for _, group := range output.Diagnostics.Groups { + if group.ReasonCode == "spell_not_near_source" { + t.Fatalf("diagnostics = %#v, want explicit validator override to replace default relatedness chain", output.Diagnostics) } } } @@ -190,8 +190,8 @@ func TestAssembledSpellPipelinePromotesTerminalUnknownSpellWarning(t *testing.T) if !reflect.DeepEqual(rejectedFile.Rejected, output.Rejected) { t.Fatalf("rejected file = %#v, run rejections = %#v, want durable rejection diagnostic", rejectedFile.Rejected, output.Rejected) } - if len(output.Warnings) != 2 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" || output.Warnings[1].ReasonCode != "spell_not_near_source" { - t.Fatalf("warnings = %#v, want complete terminal normalize validation warnings", output.Warnings) + if len(output.Diagnostics.Groups) != 2 || output.Diagnostics.Groups[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Diagnostics.Groups[0].Samples[0].Scope != "spell_casts[0]" || output.Diagnostics.Groups[1].ReasonCode != "spell_not_near_source" { + t.Fatalf("diagnostics = %#v, want complete terminal normalize validation diagnostics", output.Diagnostics) } } @@ -219,14 +219,14 @@ func TestAssembledSpellPipelinePromotesUnknownSpellWarningWhenOverrideAccepts(t if len(normalized.SpellCasts) != 1 || normalized.SpellCasts[0].Spell != "Mysterious Burst" { t.Fatalf("normalized casts = %#v, want unresolved name preserved", normalized.SpellCasts) } - if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Warnings[0].Scope != "spell_casts[0]" { - t.Fatalf("warnings = %#v, want promoted scoped unresolved-name warning", output.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != spellnormalize.ReasonCodeSpellNameUnresolved || output.Diagnostics.Groups[0].Samples[0].Scope != "spell_casts[0]" { + t.Fatalf("diagnostics = %#v, want promoted scoped unresolved-name diagnostic", output.Diagnostics) } warningsFile := decodeAssembledOutput[struct { - Warnings []contracts.Warning `json:"warnings"` + Groups []contracts.DiagnosticGroup `json:"groups"` }](t, output.OutputFiles, "warnings.json") - if !reflect.DeepEqual(warningsFile.Warnings, output.Warnings) { - t.Fatalf("warnings file = %#v, run warnings = %#v, want durable unresolved-name warning", warningsFile.Warnings, output.Warnings) + if len(warningsFile.Groups) != 0 { + t.Fatalf("warnings file = %#v, want no process warnings for an advisory diagnostic", warningsFile.Groups) } } diff --git a/internal/cli/dnd_scene_descriptions_contract_test.go b/internal/cli/dnd_scene_descriptions_contract_test.go index 5a76a2e3..aa51db75 100644 --- a/internal/cli/dnd_scene_descriptions_contract_test.go +++ b/internal/cli/dnd_scene_descriptions_contract_test.go @@ -91,8 +91,8 @@ func TestProductionSceneDescriptionWorkflow(t *testing.T) { if !reflect.DeepEqual(durable, want) { t.Fatalf("durable output payload = %#v, want %#v", durable, want) } - if len(output.Warnings) != 0 { - t.Fatalf("warnings = %#v, want grounded descriptions without warnings", output.Warnings) + if len(output.Diagnostics.Groups) != 0 { + t.Fatalf("diagnostics = %#v, want grounded descriptions without diagnostics", output.Diagnostics) } } diff --git a/internal/cli/example_contract_test.go b/internal/cli/example_contract_test.go index b524bb08..a6e68a8c 100644 --- a/internal/cli/example_contract_test.go +++ b/internal/cli/example_contract_test.go @@ -239,10 +239,10 @@ func TestMaintainedMinimalInvocationProducesJSONBundle(t *testing.T) { t.Fatalf("rejected = %#v, want empty rejection list", rejected.Rejected) } warnings := readProductionJSON[struct { - Warnings []json.RawMessage `json:"warnings"` + Groups []json.RawMessage `json:"groups"` }](t, filepath.Join(runRoot, "warnings.json")) - if len(warnings.Warnings) != 0 { - t.Fatalf("warnings = %#v, want empty warning list", warnings.Warnings) + if len(warnings.Groups) != 0 { + t.Fatalf("warnings = %#v, want empty warning list", warnings.Groups) } } diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 0695605d..563a1df5 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -852,10 +852,10 @@ func TestProductionSceneRunRecordsAnnotationFreeChunkPlanAndProvenance(t *testin t.Fatalf("chunk map range annotations = %#v, want none", chunkMap.Chunks[0].Annotations) } warnings := readProductionJSON[struct { - Warnings []contracts.Warning `json:"warnings"` + Groups []contracts.DiagnosticGroup `json:"groups"` }](t, filepath.Join(outputRoot, productionRunID, "warnings.json")) - if len(warnings.Warnings) != 0 { - t.Fatalf("warnings = %#v, want none", warnings.Warnings) + if len(warnings.Groups) != 0 { + t.Fatalf("warnings = %#v, want none", warnings.Groups) } if len(fake.requestsFor(scenes.PromptID)) != 1 || len(fake.requestsFor(spells.PromptID)) != 1 || len(fake.requestsFor(itemoccurrenceextract.PromptID)) != 1 { t.Fatalf("fake prompt requests = %#v, want one scene, spell, and item-occurrence request", fake.requestPrompts()) diff --git a/internal/cli/run.go b/internal/cli/run.go index b0d909f2..51de4d5c 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -375,7 +375,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("resolve working directory: %w", err)) } - materialized, referenceWarnings, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ + materialized, referenceDiagnostics, err := pipeline.MaterializeReferences(effective.ResolvedPipeline, catalog, pipeline.ReferenceMaterializationOptions{ ConfigPath: loadedConfigPath, WorkingDir: workingDir, }) @@ -462,7 +462,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i StartedAt: startedAt, LLMProfiles: llmProfiles, Metadata: runMetadata(effective.Config.Output.Directory, debugPath), - Diagnostics: pipeline.ReferenceDiagnostics(referenceWarnings), + Diagnostics: referenceDiagnostics, ChunkCacheMode: effective.Config.Cache.ChunkPlans.Mode, ChunkPlans: chunkPlans, Checkpoints: checkpointRecorder, @@ -513,8 +513,8 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i fmt.Fprintf(stdout, "debug=%s\n", debugPath) } } - if len(output.Warnings) > 0 { - fmt.Fprintf(stderr, "notarius: run completed with %d warning(s)\n", len(output.Warnings)) + if warningCount := warningGroupCount(output.Diagnostics); warningCount > 0 { + fmt.Fprintf(stderr, "notarius: run completed with %d warning(s)\n", warningCount) } return 0 } @@ -538,7 +538,7 @@ func writePartialSummary(summary *debugbundle.SummaryWriter, output pipeline.Run return err } } - if err := summary.WriteWarnings(output.Warnings); err != nil { + if err := summary.WriteWarnings(output.Diagnostics); err != nil { return err } return summary.WriteCheckpointEvents(output.CheckpointEvents) diff --git a/internal/cli/run_contract_test.go b/internal/cli/run_contract_test.go index b7affe0b..d9b9998b 100644 --- a/internal/cli/run_contract_test.go +++ b/internal/cli/run_contract_test.go @@ -590,7 +590,7 @@ func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) { roots := newStateTestRoots(t) harness := newStateTestHarness() harness.includeWarnings = true - harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "contract-warning", Message: "warning retained"}} + harness.chunkDiagnostics = []contracts.ProducerDiagnostic{stateTestDiagnostic("chunk", "contract-warning", "warning retained")} var stdout, stderr bytes.Buffer code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options()) if code != 0 || !strings.Contains(stdout.String(), "outputs=1") || !strings.Contains(stderr.String(), "1 warning(s)") { @@ -602,10 +602,10 @@ func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) { t.Fatalf("durable output = %q, %v", output, err) } bundle := onlyChildDir(t, roots.debug) - var warnings []contracts.Warning - readStateTestSummaryJSON(t, bundle, "warnings.json", &warnings) - if len(warnings) != 1 || warnings[0].ReasonCode != "contract-warning" { - t.Fatalf("debug warnings = %#v", warnings) + var diagnostics contracts.DiagnosticCollection + readStateTestSummaryJSON(t, bundle, "warnings.json", &diagnostics) + if len(diagnostics.Groups) != 1 || diagnostics.Groups[0].ReasonCode != "contract-warning" { + t.Fatalf("debug diagnostics = %#v", diagnostics) } } diff --git a/internal/cli/run_result.go b/internal/cli/run_result.go index a54816a4..b0c5c693 100644 --- a/internal/cli/run_result.go +++ b/internal/cli/run_result.go @@ -8,6 +8,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) @@ -59,7 +60,7 @@ func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, OutputDirectory: absOutputDirectory, NormalizedOutputCount: len(output.NormalizeOutputs), RejectedOutputCount: len(output.Rejected), - WarningCount: len(output.Warnings), + WarningCount: warningGroupCount(output.Diagnostics), ValidationStatus: output.Manifest.ValidationStatus, ValidationSummaries: cloneValidationSummaries(output.Manifest.ValidationSummaries), } @@ -88,6 +89,16 @@ func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, return result, nil } +func warningGroupCount(diagnostics contracts.DiagnosticCollection) int { + count := 0 + for _, group := range diagnostics.Groups { + if group.Disposition == contracts.DiagnosticDispositionWarning { + count++ + } + } + return count +} + func cloneValidationSummaries(summaries []artifacts.ValidationSummary) []artifacts.ValidationSummary { if len(summaries) == 0 { return nil diff --git a/internal/cli/run_result_command_test.go b/internal/cli/run_result_command_test.go index 9eef0cea..477c0f65 100644 --- a/internal/cli/run_result_command_test.go +++ b/internal/cli/run_result_command_test.go @@ -63,7 +63,7 @@ func TestMaintainedMinimalInvocationEmitsRunResult(t *testing.T) { func TestRunResultReportsWarningsAndDebugBundle(t *testing.T) { roots := newStateTestRoots(t) harness := newStateTestHarness() - harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "contract-warning", Message: "warning retained"}} + harness.chunkDiagnostics = []contracts.ProducerDiagnostic{stateTestDiagnostic("chunk", "contract-warning", "warning retained")} var stdout, stderr bytes.Buffer code := RunWithOptions([]string{ "run", "sample", "--config", roots.config, "--input", roots.input, diff --git a/internal/cli/run_result_test.go b/internal/cli/run_result_test.go index db0c9515..3245e762 100644 --- a/internal/cli/run_result_test.go +++ b/internal/cli/run_result_test.go @@ -182,8 +182,10 @@ func testRunOutput() pipeline.RunOutput { Manifest: artifacts.RunManifest{RunID: "run-123", PipelineID: "sample", ValidationStatus: "rejected"}, NormalizeOutputs: []contracts.SerializedOutput{{}, {}}, Rejected: []contracts.RejectedOutput{{}}, - Warnings: []contracts.Warning{{}}, - OutputFiles: []contracts.OutputFile{{Name: "index.json"}}, + Diagnostics: contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{{ + Disposition: contracts.DiagnosticDispositionWarning, + }}}, + OutputFiles: []contracts.OutputFile{{Name: "index.json"}}, } } diff --git a/internal/cli/run_terminal.go b/internal/cli/run_terminal.go index d4e1af69..e4d2ea36 100644 --- a/internal/cli/run_terminal.go +++ b/internal/cli/run_terminal.go @@ -39,7 +39,7 @@ func (s *pipelineCommandState) observeOutput(output pipeline.RunOutput) { } s.report.OutputCount = len(output.NormalizeOutputs) s.report.RejectedCount = len(output.Rejected) - s.report.WarningCount = len(output.Warnings) + s.report.WarningCount = warningGroupCount(output.Diagnostics) s.report.ValidationStatus = output.Manifest.ValidationStatus } diff --git a/internal/cli/spell_catalog_identity_contract_test.go b/internal/cli/spell_catalog_identity_contract_test.go index c1d884c2..ef79297a 100644 --- a/internal/cli/spell_catalog_identity_contract_test.go +++ b/internal/cli/spell_catalog_identity_contract_test.go @@ -238,7 +238,7 @@ func TestChangedSemanticSpellCatalogFingerprintCannotResumeRecordedCheckpoint(t Kind: dnd.SpellListKind, Schema: normalizeSchema, MediaType: "application/json", Content: []byte(`{"spell_casts":[]}`), }, } - if err := recorder.NormalizeSucceeded("spells", spellnormalize.Key, normalizeDependencies, normalizeArtifact, nil); err != nil { + if err := recorder.NormalizeSucceeded("spells", spellnormalize.Key, normalizeDependencies, normalizeArtifact); err != nil { t.Fatal(err) } diff --git a/internal/cli/spell_catalog_retry_contract_test.go b/internal/cli/spell_catalog_retry_contract_test.go index d3934ee2..961a82eb 100644 --- a/internal/cli/spell_catalog_retry_contract_test.go +++ b/internal/cli/spell_catalog_retry_contract_test.go @@ -92,8 +92,8 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) { if rejection.ReasonCode != "unknown_spell" || rejection.AttemptCount != retries+1 { t.Fatalf("rejection = %#v, want exhausted unknown-spell rejection", rejection) } - if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != "spell_not_near_source" { - t.Fatalf("warnings = %#v, want complete terminal validation warnings", output.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != "spell_not_near_source" { + t.Fatalf("diagnostics = %#v, want complete terminal validation diagnostic", output.Diagnostics) } return } @@ -108,8 +108,8 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) { if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell { t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value) } - if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != tt.wantWarningCode { - t.Fatalf("warnings = %#v, want the terminal extract compatibility projection", output.Warnings) + if len(output.Diagnostics.Groups) != 2 || output.Diagnostics.Groups[0].ReasonCode != tt.wantWarningCode { + t.Fatalf("diagnostics = %#v, want terminal extract and normalize diagnostics", output.Diagnostics) } if len(output.Diagnostics.Groups) != 2 { t.Fatalf("diagnostics = %#v, want extract and normalize data-quality advisories", output.Diagnostics) diff --git a/internal/cli/state_hardening_test.go b/internal/cli/state_hardening_test.go index b5dc531a..11d7f9f2 100644 --- a/internal/cli/state_hardening_test.go +++ b/internal/cli/state_hardening_test.go @@ -445,7 +445,7 @@ func TestRunWritesTerminalArtifactsForResolutionPipelineAndOutputFailures(t *tes func TestRunRetainsPartialPipelineOutcomeInFailureSummary(t *testing.T) { roots := newStateTestRoots(t) harness := newStateTestHarness() - harness.chunkWarnings = []contracts.Warning{{Scope: "chunk", ReasonCode: "partial-warning", Message: "warning retained before failure"}} + harness.chunkDiagnostics = []contracts.ProducerDiagnostic{stateTestDiagnostic("chunk", "partial-warning", "warning retained before failure")} harness.extractErr = errors.New("synthetic partial pipeline failure") result := runStateTest(t, roots, harness.options(), true, true, "bypass") @@ -463,10 +463,10 @@ func TestRunRetainsPartialPipelineOutcomeInFailureSummary(t *testing.T) { if manifest.RunID != report.RunID || manifest.PipelineID != "sample" || manifest.ValidationStatus != "failed" { t.Fatalf("partial manifest = %#v", manifest) } - var warnings []contracts.Warning - readStateTestSummaryJSON(t, bundlePath, "warnings.json", &warnings) - if len(warnings) != 1 || warnings[0].ReasonCode != "partial-warning" { - t.Fatalf("partial warnings = %#v", warnings) + var diagnostics contracts.DiagnosticCollection + readStateTestSummaryJSON(t, bundlePath, "warnings.json", &diagnostics) + if len(diagnostics.Groups) != 1 || diagnostics.Groups[0].ReasonCode != "partial-warning" { + t.Fatalf("partial diagnostics = %#v", diagnostics) } var events []pipeline.CheckpointEvent readStateTestSummaryJSON(t, bundlePath, "checkpoint-events.json", &events) @@ -863,10 +863,10 @@ type stateTestHarness struct { chunkCalls, extractCalls int runIDCalls uint64 extractErr error - chunkWarnings []contracts.Warning + chunkDiagnostics []contracts.ProducerDiagnostic moduleProfiles []string sessionIDs []string - outputWarnings []contracts.Warning + outputDiagnostics contracts.DiagnosticCollection includeWarnings bool } @@ -892,7 +892,7 @@ func (h *stateTestHarness) options() Options { panic(err) } if err := registries.Outputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "test/output", Stage: pipeline.StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"normalized"}, Provides: []string{"output"}}, func() (contracts.OutputEncoder, error) { - return stateTestOutput{harness: h, includeWarnings: h.includeWarnings}, nil + return stateTestOutput{harness: h, includeDiagnostics: h.includeWarnings}, nil }); err != nil { panic(err) } @@ -931,7 +931,7 @@ func (c stateTestChunker) Plan(_ context.Context, req contracts.ChunkRequest) (c c.harness.mu.Lock() c.harness.chunkCalls++ c.harness.mu.Unlock() - return contracts.ChunkPlanResult{Plan: source.ChunkPlan{SourceDigest: req.Source.Digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 1}}}, Warnings: append([]contracts.Warning(nil), c.harness.chunkWarnings...)}, nil + return contracts.ChunkPlanResult{Plan: source.ChunkPlan{SourceDigest: req.Source.Digest, Ranges: []source.ChunkRange{{StartUnitID: 1, EndUnitID: 1}}}, Diagnostics: contracts.CloneProducerDiagnostics(c.harness.chunkDiagnostics)}, nil } const stateTestArtifactKind contracts.ArtifactKind = "test/artifact" @@ -999,22 +999,26 @@ func (n stateTestNormalizer) Normalize(_ context.Context, req contracts.TypedNor } type stateTestOutput struct { - harness *stateTestHarness - includeWarnings bool + harness *stateTestHarness + includeDiagnostics bool } func (o stateTestOutput) Key() string { return "test/output" } func (o stateTestOutput) Encode(_ context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { o.harness.mu.Lock() - o.harness.outputWarnings = append([]contracts.Warning(nil), req.Warnings...) + o.harness.outputDiagnostics = contracts.CloneDiagnosticCollection(req.Diagnostics) o.harness.mu.Unlock() data := []byte("{\"ok\":true}\n") - if o.includeWarnings && len(req.Warnings) > 0 { - data = []byte(fmt.Sprintf("{\"ok\":true,\"warnings\":%q}\n", req.Warnings[0].ReasonCode)) + if o.includeDiagnostics && len(req.Diagnostics.Groups) > 0 { + data = []byte(fmt.Sprintf("{\"ok\":true,\"diagnostics\":%q}\n", req.Diagnostics.Groups[0].ReasonCode)) } return contracts.OutputResult{Files: []contracts.OutputFile{{Name: "result.json", Bytes: data}}}, nil } +func stateTestDiagnostic(scope, reasonCode, message string) contracts.ProducerDiagnostic { + return contracts.ProducerDiagnostic{Disposition: contracts.DiagnosticDispositionWarning, Category: contracts.DiagnosticCategoryDegradation, ReasonCode: reasonCode, OccurrenceCount: 1, Samples: []contracts.DiagnosticSample{{Scope: scope, Message: message}}} +} + type failingDebugRecorder struct{} func (failingDebugRecorder) Enabled() bool { return true } diff --git a/internal/core/debugbundle/bundle_test.go b/internal/core/debugbundle/bundle_test.go index 7906246e..9869a766 100644 --- a/internal/core/debugbundle/bundle_test.go +++ b/internal/core/debugbundle/bundle_test.go @@ -110,7 +110,7 @@ func TestSummaryWriterWritesEverySummaryArtifact(t *testing.T) { if err := summary.WriteRunReport(RunReport{RunID: bundle.RunID(), PipelineID: "test"}); err != nil { t.Fatal(err) } - if err := summary.WriteWarnings([]contracts.Warning{{ReasonCode: "test"}}); err != nil { + if err := summary.WriteWarnings(contracts.DiagnosticCollection{}); err != nil { t.Fatal(err) } if err := summary.WriteError("failed"); err != nil { diff --git a/internal/core/debugbundle/summary.go b/internal/core/debugbundle/summary.go index 2ac80539..cac0ab5b 100644 --- a/internal/core/debugbundle/summary.go +++ b/internal/core/debugbundle/summary.go @@ -101,7 +101,7 @@ func (w *SummaryWriter) WriteChunkPlan(v artifacts.ChunkPlanSummary) error { return w.writeJSON(ArtifactChunkPlan, v) } func (w *SummaryWriter) WriteRunReport(v RunReport) error { return w.writeJSON(ArtifactRunReport, v) } -func (w *SummaryWriter) WriteWarnings(v []contracts.Warning) error { +func (w *SummaryWriter) WriteWarnings(v contracts.DiagnosticCollection) error { return w.writeJSON(ArtifactWarnings, v) } func (w *SummaryWriter) WriteError(message string) error { diff --git a/internal/framework/checkpoint/filesystem_test.go b/internal/framework/checkpoint/filesystem_test.go index a755e079..d9914f87 100644 --- a/internal/framework/checkpoint/filesystem_test.go +++ b/internal/framework/checkpoint/filesystem_test.go @@ -24,7 +24,6 @@ type filesystemCheckpointFixture struct { merge pipeline.CheckpointArtifact normalize pipeline.CheckpointArtifact dependencies []pipeline.CheckpointFingerprint - warnings []contracts.Warning rejected []contracts.RejectedOutput } @@ -39,7 +38,6 @@ func TestFilesystemCheckpointRoundTripsAllStages(t *testing.T) { fixture.extract.Diagnostics[0].Diagnostic.Samples[0].Message = "caller mutation" fixture.merge.Artifact.Content[0] = 'x' fixture.normalize.Artifact.Content[0] = 'x' - fixture.warnings[0].Message = "caller mutation" fixture.rejected[0].Message = "caller mutation" t.Run("source", func(t *testing.T) { @@ -61,11 +59,11 @@ func TestFilesystemCheckpointRoundTripsAllStages(t *testing.T) { t.Run("extract", func(t *testing.T) { got, decision := fixture.loader.Extract("lane-a", "extract-module", fixture.dependencies) - if !decision.Reused || len(got.Outputs) != 1 || len(got.Rejected) != 1 || len(got.Warnings) != 1 || len(got.Outputs[0].Diagnostics) != 1 { + if !decision.Reused || len(got.Outputs) != 1 || len(got.Rejected) != 1 || len(got.Outputs[0].Diagnostics) != 1 { t.Fatalf("extract result=%#v decision=%#v", got, decision) } output := got.Outputs[0] - if !bytes.Equal(output.Artifact.Content, []byte(`{"spell":"fire"}`)) || output.Artifact.Kind != "spell" || output.Artifact.Schema.ID != "spell-schema" || output.Artifact.Schema.Version != "1" || output.Artifact.MediaType != "application/json" || output.Artifact.Metadata["chunk"] != "chunk-a" || output.ChunkRef.StartUnitID != 1 || output.Diagnostics[0].Diagnostic.ReasonCode != "normalized_record" || got.Warnings[0].ReasonCode != "partial" || got.Rejected[0].ReasonCode != "invalid_source" { + if !bytes.Equal(output.Artifact.Content, []byte(`{"spell":"fire"}`)) || output.Artifact.Kind != "spell" || output.Artifact.Schema.ID != "spell-schema" || output.Artifact.Schema.Version != "1" || output.Artifact.MediaType != "application/json" || output.Artifact.Metadata["chunk"] != "chunk-a" || output.ChunkRef.StartUnitID != 1 || output.Diagnostics[0].Diagnostic.ReasonCode != "normalized_record" || got.Rejected[0].ReasonCode != "invalid_source" { t.Fatalf("extract values were not restored: %#v", got) } manifest := readManifest[ExtractLaneManifest](t, filepath.Join(fixture.root, mustRelativePath(t, fixture.identity), "extract", "lane-a", "manifest.json")) @@ -74,37 +72,36 @@ func TestFilesystemCheckpointRoundTripsAllStages(t *testing.T) { } got.Outputs[0].Artifact.Content[0] = 'y' - got.Warnings[0].Message = "loaded mutation" reloaded, decision := fixture.loader.Extract("lane-a", "extract-module", fixture.dependencies) - if !decision.Reused || !bytes.Equal(reloaded.Outputs[0].Artifact.Content, []byte(`{"spell":"fire"}`)) || reloaded.Warnings[0].Message != "partial output" { + if !decision.Reused || !bytes.Equal(reloaded.Outputs[0].Artifact.Content, []byte(`{"spell":"fire"}`)) { t.Fatalf("extract reload changed after loaded mutation: %#v decision=%#v", reloaded, decision) } }) for _, tt := range []struct { name string - load func() (pipeline.CheckpointArtifact, []contracts.Warning, pipeline.CheckpointDecision) + load func() (pipeline.CheckpointArtifact, pipeline.CheckpointDecision) want []byte }{ - {name: "merge", load: func() (pipeline.CheckpointArtifact, []contracts.Warning, pipeline.CheckpointDecision) { + {name: "merge", load: func() (pipeline.CheckpointArtifact, pipeline.CheckpointDecision) { got, decision := fixture.loader.Merge("lane-a", "merge-module", fixture.dependencies) - return got.Output, got.Warnings, decision + return got.Output, decision }, want: []byte(`{"spells":["fire"]}`)}, - {name: "normalize", load: func() (pipeline.CheckpointArtifact, []contracts.Warning, pipeline.CheckpointDecision) { + {name: "normalize", load: func() (pipeline.CheckpointArtifact, pipeline.CheckpointDecision) { got, decision := fixture.loader.Normalize("lane-a", "normalize-module", fixture.dependencies) - return got.Output, got.Warnings, decision + return got.Output, decision }, want: []byte(`{"spells":["fire"],"normalized":true}`)}, } { t.Run(tt.name, func(t *testing.T) { - got, warnings, decision := tt.load() - if !decision.Reused || !bytes.Equal(got.Artifact.Content, tt.want) || got.Artifact.Kind != "spell" || got.Artifact.Schema.ID != "spell-schema" || got.Artifact.Schema.Version != "1" || got.Artifact.Metadata["lane"] != "lane-a" || len(got.Diagnostics) != 1 || got.Diagnostics[0].Diagnostic.ReasonCode != "normalized_record" || len(warnings) != 1 || warnings[0].ReasonCode != "review" { - t.Fatalf("%s result=%#v warnings=%#v decision=%#v", tt.name, got, warnings, decision) + got, decision := tt.load() + if !decision.Reused || !bytes.Equal(got.Artifact.Content, tt.want) || got.Artifact.Kind != "spell" || got.Artifact.Schema.ID != "spell-schema" || got.Artifact.Schema.Version != "1" || got.Artifact.Metadata["lane"] != "lane-a" || len(got.Diagnostics) != 1 || got.Diagnostics[0].Diagnostic.ReasonCode != "normalized_record" { + t.Fatalf("%s result=%#v decision=%#v", tt.name, got, decision) } got.Artifact.Content[0] = 'z' - reloaded, warnings, decision := tt.load() - if !decision.Reused || !bytes.Equal(reloaded.Artifact.Content, tt.want) || warnings[0].Message != "review manually" { - t.Fatalf("%s reload changed after loaded mutation: %#v warnings=%#v decision=%#v", tt.name, reloaded, warnings, decision) + reloaded, decision := tt.load() + if !decision.Reused || !bytes.Equal(reloaded.Artifact.Content, tt.want) { + t.Fatalf("%s reload changed after loaded mutation: %#v decision=%#v", tt.name, reloaded, decision) } }) } @@ -344,9 +341,6 @@ func TestFilesystemLoaderReadsAcceptedNormalizeWithoutStageDependencies(t *testi if checkpoint.Output.Artifact.Content == nil || string(checkpoint.Output.Artifact.Content) != string(fixture.normalize.Artifact.Content) { t.Fatalf("accepted normalize output = %#v, want recorded artifact", checkpoint.Output) } - if len(checkpoint.Warnings) != 1 || checkpoint.Warnings[0].ReasonCode != "normalized" { - t.Fatalf("accepted normalize warnings = %#v", checkpoint.Warnings) - } } func TestFilesystemLoaderRejectsInvalidAcceptedNormalize(t *testing.T) { @@ -409,8 +403,7 @@ func seedAcceptedNormalizeCheckpoint(t *testing.T) filesystemCheckpointFixture { t.Fatal(err) } stepRecorder := recorder.(pipeline.StepCheckpointRecorder) - warnings := []contracts.Warning{{Scope: "normalize", ReasonCode: "normalized", Message: "normalized warning"}} - if err := stepRecorder.NormalizeSucceededForStep("step-1", "lane-a", "normalize-module", []pipeline.CheckpointFingerprint{{Name: "merge", Value: "sha256:unavailable"}}, artifact, warnings); err != nil { + if err := stepRecorder.NormalizeSucceededForStep("step-1", "lane-a", "normalize-module", []pipeline.CheckpointFingerprint{{Name: "merge", Value: "sha256:unavailable"}}, artifact); err != nil { t.Fatal(err) } loader, err := NewFilesystemLoader(root, identity) @@ -500,20 +493,18 @@ func seedFilesystemCheckpoints(t *testing.T) filesystemCheckpointFixture { merge: checkpointArtifact("merge", `{"spells":["fire"]}`), normalize: checkpointArtifact("normalize", `{"spells":["fire"],"normalized":true}`), dependencies: []pipeline.CheckpointFingerprint{{Name: "source", Value: "sha256:source"}, {Name: "chunk-plan", Value: "sha256:plan"}}, - warnings: []contracts.Warning{{Scope: "extract", ReasonCode: "partial", Message: "partial output"}}, rejected: []contracts.RejectedOutput{{Stage: "extract", LaneID: "lane-a", ModuleKey: "extract-module", ChunkID: "chunk-a", ValidatorName: "source_refs", ReasonCode: "invalid_source", Message: "source reference is invalid", AttemptCount: 1}}, } if err := recorder.SourceSucceeded("source-module", &fixture.doc); err != nil { t.Fatal(err) } - if err := recorder.ExtractSucceeded("lane-a", "extract-module", fixture.dependencies, []pipeline.CheckpointArtifact{fixture.extract}, fixture.rejected, fixture.warnings); err != nil { + if err := recorder.ExtractSucceeded("lane-a", "extract-module", fixture.dependencies, []pipeline.CheckpointArtifact{fixture.extract}, fixture.rejected); err != nil { t.Fatal(err) } - mergeWarnings := []contracts.Warning{{Scope: "merge", ReasonCode: "review", Message: "review manually"}} - if err := recorder.MergeSucceeded("lane-a", "merge-module", fixture.dependencies, fixture.merge, mergeWarnings); err != nil { + if err := recorder.MergeSucceeded("lane-a", "merge-module", fixture.dependencies, fixture.merge); err != nil { t.Fatal(err) } - if err := recorder.NormalizeSucceeded("lane-a", "normalize-module", fixture.dependencies, fixture.normalize, mergeWarnings); err != nil { + if err := recorder.NormalizeSucceeded("lane-a", "normalize-module", fixture.dependencies, fixture.normalize); err != nil { t.Fatal(err) } fixture.loader, err = NewFilesystemLoader(root, identity) diff --git a/internal/framework/checkpoint/loader.go b/internal/framework/checkpoint/loader.go index 5426fab7..2f2e0e04 100644 --- a/internal/framework/checkpoint/loader.go +++ b/internal/framework/checkpoint/loader.go @@ -86,7 +86,7 @@ func (l *FilesystemLoader) ExtractForStep(stepID, laneID, moduleKey string, depe if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(outputs)) { return pipeline.ExtractCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch) } - return pipeline.ExtractCheckpoint{Outputs: outputs, Rejected: cloneRejectedOutputs(payload.Rejected), Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() + return pipeline.ExtractCheckpoint{Outputs: outputs, Rejected: cloneRejectedOutputs(payload.Rejected)}, reusedDecision() } func (l *FilesystemLoader) Merge(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.MergeCheckpoint, pipeline.CheckpointDecision) { @@ -112,7 +112,7 @@ func (l *FilesystemLoader) MergeForStep(stepID, laneID, moduleKey string, depend if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { return pipeline.MergeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch) } - return pipeline.MergeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() + return pipeline.MergeCheckpoint{Output: values[0]}, reusedDecision() } func (l *FilesystemLoader) Normalize(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.NormalizeCheckpoint, pipeline.CheckpointDecision) { @@ -138,7 +138,7 @@ func (l *FilesystemLoader) NormalizeForStep(stepID, laneID, moduleKey string, de if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch) } - return pipeline.NormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() + return pipeline.NormalizeCheckpoint{Output: values[0]}, reusedDecision() } func (l *FilesystemLoader) AcceptedNormalize(stepID, laneID, moduleKey string) (pipeline.NormalizeCheckpoint, pipeline.CheckpointDecision) { @@ -163,7 +163,7 @@ func (l *FilesystemLoader) AcceptedNormalize(stepID, laneID, moduleKey string) ( if !fingerprintsEqual(checkpointToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { return pipeline.NormalizeCheckpoint{}, decision(pipeline.CheckpointDecisionExecuted, pipeline.CheckpointReasonArtifactDigestMismatch) } - return pipeline.NormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, decision(pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonAcceptedArtifactReused) + return pipeline.NormalizeCheckpoint{Output: values[0]}, decision(pipeline.CheckpointDecisionReused, pipeline.CheckpointReasonAcceptedArtifactReused) } func (l *FilesystemLoader) validateAcceptedNormalizeManifest(manifest StageManifest, stepID, laneID, moduleKey string) pipeline.CheckpointDecision { diff --git a/internal/framework/checkpoint/recorder.go b/internal/framework/checkpoint/recorder.go index f2cc713c..aff1deac 100644 --- a/internal/framework/checkpoint/recorder.go +++ b/internal/framework/checkpoint/recorder.go @@ -81,18 +81,18 @@ func (r *FilesystemRecorder) ExtractRunningForStep(stepID, laneID string, module return r.writeManifest(laneManifestPath("extract", stepID, laneID), ExtractLaneManifest{StageManifest: manifest}) } -func (r *FilesystemRecorder) ExtractSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, outputs []pipeline.CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { - return r.ExtractSucceededForStep("", laneID, moduleKey, dependencies, outputs, rejected, warnings) +func (r *FilesystemRecorder) ExtractSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, outputs []pipeline.CheckpointArtifact, rejected []contracts.RejectedOutput) error { + return r.ExtractSucceededForStep("", laneID, moduleKey, dependencies, outputs, rejected) } -func (r *FilesystemRecorder) ExtractSucceededForStep(stepID, laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, outputs []pipeline.CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { - payload := artifactExtractEnvelope{Outputs: artifactCheckpointEnvelopes(outputs), Rejected: cloneRejectedOutputs(rejected), Warnings: cloneWarnings(warnings)} +func (r *FilesystemRecorder) ExtractSucceededForStep(stepID, laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, outputs []pipeline.CheckpointArtifact, rejected []contracts.RejectedOutput) error { + payload := artifactExtractEnvelope{Outputs: artifactCheckpointEnvelopes(outputs), Rejected: cloneRejectedOutputs(rejected)} if err := r.writePayload(lanePayloadPath("extract", stepID, laneID, "outputs.json"), payload); err != nil { return err } manifest := r.laneManifest(StageExtract, statusForRejected(rejected), stepID, laneID, moduleKey, dependencies) manifest.OutputDigests = checkpointFingerprints(artifactOutputDigests(outputs)) - manifest.ValidationStatus = validationStatusString(warnings, rejected) + manifest.ValidationStatus = validationStatusString(rejected) manifest.Rejections = rejectionSummaries(rejected) manifest.CompletedAt = timePtr(r.timestamp()) return r.writeManifest(laneManifestPath("extract", stepID, laneID), ExtractLaneManifest{StageManifest: manifest, ChunkCount: len(outputs) + len(rejected), OutputCount: len(outputs)}) @@ -119,17 +119,17 @@ func (r *FilesystemRecorder) MergeRunningForStep(stepID, laneID string, moduleKe return r.writeManifest(laneManifestPath("merge", stepID, laneID), MergeLaneManifest{StageManifest: manifest}) } -func (r *FilesystemRecorder) MergeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact, warnings []contracts.Warning) error { - return r.MergeSucceededForStep("", laneID, moduleKey, dependencies, output, warnings) +func (r *FilesystemRecorder) MergeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact) error { + return r.MergeSucceededForStep("", laneID, moduleKey, dependencies, output) } -func (r *FilesystemRecorder) MergeSucceededForStep(stepID, laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact, warnings []contracts.Warning) error { - if err := r.writePayload(lanePayloadPath("merge", stepID, laneID, "output.json"), artifactSingleEnvelope{Output: artifactCheckpointEnvelopeFromOutput(output), Warnings: cloneWarnings(warnings)}); err != nil { +func (r *FilesystemRecorder) MergeSucceededForStep(stepID, laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact) error { + if err := r.writePayload(lanePayloadPath("merge", stepID, laneID, "output.json"), artifactSingleEnvelope{Output: artifactCheckpointEnvelopeFromOutput(output)}); err != nil { return err } manifest := r.laneManifest(StageMerge, StatusSucceeded, stepID, laneID, moduleKey, dependencies) manifest.OutputDigests = checkpointFingerprints(artifactOutputDigests([]pipeline.CheckpointArtifact{output})) - manifest.ValidationStatus = validationStatusString(warnings, nil) + manifest.ValidationStatus = validationStatusString(nil) manifest.CompletedAt = timePtr(r.timestamp()) return r.writeManifest(laneManifestPath("merge", stepID, laneID), MergeLaneManifest{StageManifest: manifest, InputCount: len(dependencies)}) } @@ -167,17 +167,17 @@ func (r *FilesystemRecorder) NormalizeRunningForStep(stepID, laneID string, modu return r.writeManifest(laneManifestPath("normalize", stepID, laneID), NormalizeLaneManifest{StageManifest: manifest}) } -func (r *FilesystemRecorder) NormalizeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact, warnings []contracts.Warning) error { - return r.NormalizeSucceededForStep("", laneID, moduleKey, dependencies, output, warnings) +func (r *FilesystemRecorder) NormalizeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact) error { + return r.NormalizeSucceededForStep("", laneID, moduleKey, dependencies, output) } -func (r *FilesystemRecorder) NormalizeSucceededForStep(stepID, laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact, warnings []contracts.Warning) error { - if err := r.writePayload(lanePayloadPath("normalize", stepID, laneID, "output.json"), artifactSingleEnvelope{Output: artifactCheckpointEnvelopeFromOutput(output), Warnings: cloneWarnings(warnings)}); err != nil { +func (r *FilesystemRecorder) NormalizeSucceededForStep(stepID, laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact) error { + if err := r.writePayload(lanePayloadPath("normalize", stepID, laneID, "output.json"), artifactSingleEnvelope{Output: artifactCheckpointEnvelopeFromOutput(output)}); err != nil { return err } manifest := r.laneManifest(StageNormalize, StatusSucceeded, stepID, laneID, moduleKey, dependencies) manifest.OutputDigests = checkpointFingerprints(artifactOutputDigests([]pipeline.CheckpointArtifact{output})) - manifest.ValidationStatus = validationStatusString(warnings, nil) + manifest.ValidationStatus = validationStatusString(nil) manifest.CompletedAt = timePtr(r.timestamp()) return r.writeManifest(laneManifestPath("normalize", stepID, laneID), NormalizeLaneManifest{StageManifest: manifest, InputCount: len(dependencies)}) } @@ -249,11 +249,10 @@ type sourceDocumentEnvelope struct { } type binaryEnvelope struct { - ContentBase64 string `json:"content_base64,omitempty"` - ContentDigest string `json:"content_digest,omitempty"` - MediaType string `json:"media_type,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - Warnings []contracts.Warning `json:"warnings,omitempty"` + ContentBase64 string `json:"content_base64,omitempty"` + ContentDigest string `json:"content_digest,omitempty"` + MediaType string `json:"media_type,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } type artifactCheckpointEnvelope struct { @@ -272,17 +271,15 @@ type artifactCheckpointEnvelope struct { type artifactExtractEnvelope struct { Outputs []artifactCheckpointEnvelope `json:"outputs"` Rejected []contracts.RejectedOutput `json:"rejected,omitempty"` - Warnings []contracts.Warning `json:"warnings,omitempty"` } type artifactSingleEnvelope struct { - Output artifactCheckpointEnvelope `json:"output"` - Warnings []contracts.Warning `json:"warnings,omitempty"` + Output artifactCheckpointEnvelope `json:"output"` } func artifactCheckpointEnvelopeFromOutput(output pipeline.CheckpointArtifact) artifactCheckpointEnvelope { schema := contracts.CloneArtifactSchema(output.Artifact.Schema) schema.JSONSchema = nil - return artifactCheckpointEnvelope{LaneID: output.LaneID, ModuleKey: output.ModuleKey, SourceID: output.SourceID, ChunkID: output.ChunkID, ChunkIndex: output.ChunkIndex, ChunkRef: output.ChunkRef, Kind: output.Artifact.Kind, Schema: schema, SchemaDigest: output.SchemaDigest, Content: binaryEnvelopeFromContent(output.Artifact.Content, output.Artifact.MediaType, output.Artifact.Metadata, nil), Diagnostics: cloneCheckpointDiagnostics(output.Diagnostics)} + return artifactCheckpointEnvelope{LaneID: output.LaneID, ModuleKey: output.ModuleKey, SourceID: output.SourceID, ChunkID: output.ChunkID, ChunkIndex: output.ChunkIndex, ChunkRef: output.ChunkRef, Kind: output.Artifact.Kind, Schema: schema, SchemaDigest: output.SchemaDigest, Content: binaryEnvelopeFromContent(output.Artifact.Content, output.Artifact.MediaType, output.Artifact.Metadata), Diagnostics: cloneCheckpointDiagnostics(output.Diagnostics)} } func artifactCheckpointEnvelopes(outputs []pipeline.CheckpointArtifact) []artifactCheckpointEnvelope { if len(outputs) == 0 { @@ -316,13 +313,12 @@ func artifactOutputDigests(outputs []pipeline.CheckpointArtifact) []pipeline.Che return normalizeFingerprints(values) } -func binaryEnvelopeFromContent(content []byte, mediaType string, metadata map[string]any, warnings []contracts.Warning) binaryEnvelope { +func binaryEnvelopeFromContent(content []byte, mediaType string, metadata map[string]any) binaryEnvelope { return binaryEnvelope{ ContentBase64: base64.StdEncoding.EncodeToString(content), ContentDigest: contentDigest(content), MediaType: mediaType, Metadata: cloneMetadata(metadata), - Warnings: cloneWarnings(warnings), } } @@ -349,13 +345,6 @@ func cloneSourceUnits(units []source.SourceUnit) []source.SourceUnit { return out } -func cloneWarnings(warnings []contracts.Warning) []contracts.Warning { - if len(warnings) == 0 { - return nil - } - return append([]contracts.Warning(nil), warnings...) -} - func cloneRejectedOutputs(rejected []contracts.RejectedOutput) []contracts.RejectedOutput { if len(rejected) == 0 { return nil @@ -476,13 +465,10 @@ func statusForRejected(rejected []contracts.RejectedOutput) StageStatus { return StatusSucceeded } -func validationStatusString(warnings []contracts.Warning, rejected []contracts.RejectedOutput) string { +func validationStatusString(rejected []contracts.RejectedOutput) string { if len(rejected) > 0 { return "rejected" } - if len(warnings) > 0 { - return "approved_with_warnings" - } return "approved" } diff --git a/internal/framework/checkpoint/recorder_test.go b/internal/framework/checkpoint/recorder_test.go index 769411d5..6be6e61c 100644 --- a/internal/framework/checkpoint/recorder_test.go +++ b/internal/framework/checkpoint/recorder_test.go @@ -17,7 +17,7 @@ func TestRootBasedRecorderOutputIsReusable(t *testing.T) { if err != nil { t.Fatal(err) } - if err := recorder.ExtractSucceeded("lane", "module", nil, nil, nil, nil); err != nil { + if err := recorder.ExtractSucceeded("lane", "module", nil, nil, nil); err != nil { t.Fatal(err) } loader, err := NewFilesystemLoader(root, identity) @@ -44,7 +44,7 @@ func TestStepAwareRecorderAndLoaderIsolateLaneState(t *testing.T) { if err != nil { t.Fatal(err) } - if err := recorder.(pipeline.StepCheckpointRecorder).ExtractSucceededForStep("step-a", "lane", "module", nil, nil, nil, nil); err != nil { + if err := recorder.(pipeline.StepCheckpointRecorder).ExtractSucceededForStep("step-a", "lane", "module", nil, nil, nil); err != nil { t.Fatal(err) } loader, err := NewFilesystemLoader(root, identity) @@ -87,7 +87,7 @@ func TestStepAwareCheckpointPreservesDistinctDotIdentities(t *testing.T) { LaneID: "lane", ModuleKey: "normalize-module", SourceID: "source", ChunkID: "chunk", ChunkRef: source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}, SchemaDigest: "sha256:schema", Artifact: contracts.SerializedArtifact{Kind: "kind", Schema: contracts.ArtifactSchema{ID: "schema", Name: "Schema", Version: "1"}, MediaType: "application/json", Content: []byte(test.content)}, } - if err := stepRecorder.NormalizeSucceededForStep(test.stepID, "lane", "normalize-module", nil, artifact, nil); err != nil { + if err := stepRecorder.NormalizeSucceededForStep(test.stepID, "lane", "normalize-module", nil, artifact); err != nil { t.Fatalf("record %q: %v", test.stepID, err) } } diff --git a/internal/framework/chunkplan/store_test.go b/internal/framework/chunkplan/store_test.go index dcdca15d..a3b28d68 100644 --- a/internal/framework/chunkplan/store_test.go +++ b/internal/framework/chunkplan/store_test.go @@ -498,7 +498,6 @@ func testRecord(t *testing.T, value int) pipeline.ChunkPlanRecord { References: []artifacts.ReferenceProvenance{{Stage: "chunk", SlotName: "guide", OriginType: "file", OriginURI: "file:///guide.txt", Digest: "sha256:reference"}}, Metadata: map[string]any{"prompt_id": "test/prompt", "enabled": true}, }, - Warnings: []contracts.Warning{{Scope: "chunk/test", ReasonCode: "observed", Message: "warning"}}, Diagnostics: []contracts.ProducerDiagnostic{{ Disposition: contracts.DiagnosticDispositionWarning, Category: contracts.DiagnosticCategoryConfiguration, diff --git a/internal/framework/contracts/contracts.go b/internal/framework/contracts/contracts.go index cac0e536..4712965e 100644 --- a/internal/framework/contracts/contracts.go +++ b/internal/framework/contracts/contracts.go @@ -163,7 +163,6 @@ type ChunkRequest struct { type ChunkPlanResult struct { Plan source.ChunkPlan `json:"plan"` - Warnings []Warning `json:"warnings,omitempty"` Diagnostics []ProducerDiagnostic `json:"diagnostics,omitempty"` ModelCandidate *ModelCandidate `json:"-"` } @@ -289,21 +288,13 @@ type ValidationResult struct { Message string `json:"message,omitempty"` CorrectionGuidance string `json:"-"` DiagnosticArtifactPath string `json:"diagnostic_artifact_path,omitempty"` - Warnings []Warning `json:"warnings,omitempty"` Diagnostics []ProducerDiagnostic `json:"diagnostics,omitempty"` } -type Warning struct { - Scope string `json:"scope,omitempty"` - ReasonCode string `json:"reason_code"` - Message string `json:"message"` -} - type OutputRequest struct { Manifest artifacts.RunManifest `json:"manifest"` NormalizeOutputs []SerializedOutput `json:"normalize_outputs,omitempty"` Rejected []RejectedOutput `json:"rejected,omitempty"` - Warnings []Warning `json:"warnings,omitempty"` Diagnostics DiagnosticCollection `json:"diagnostics,omitempty"` LLMProfile string `json:"llm_profile,omitempty"` StructuredOutputRepairAttempts *int `json:"structured_output_repair_attempts,omitempty"` diff --git a/internal/framework/contracts/typed_pipeline.go b/internal/framework/contracts/typed_pipeline.go index e74596e3..7572a86d 100644 --- a/internal/framework/contracts/typed_pipeline.go +++ b/internal/framework/contracts/typed_pipeline.go @@ -48,7 +48,6 @@ type TypedExtractionRequest struct { type TypedExtractionResult[T any] struct { Value T - Warnings []Warning Diagnostics []ProducerDiagnostic ModelCandidate *ModelCandidate } @@ -74,7 +73,6 @@ type TypedMergeRequest[T any] struct { type TypedMergeResult[T any] struct { Value T - Warnings []Warning Diagnostics []ProducerDiagnostic ModelCandidate *ModelCandidate } @@ -99,7 +97,6 @@ type TypedNormalizeRequest[T any] struct { type TypedNormalizeResult[T any] struct { Value T - Warnings []Warning Diagnostics []ProducerDiagnostic Retry *NormalizeRetry ModelCandidate *ModelCandidate @@ -117,7 +114,6 @@ const ( type NormalizeRetry struct { ReasonCode string Message string - FallbackWarnings []Warning FallbackDiagnostics []ProducerDiagnostic } diff --git a/internal/framework/pipeline/checkpoint.go b/internal/framework/pipeline/checkpoint.go index 73b6ce14..63a62246 100644 --- a/internal/framework/pipeline/checkpoint.go +++ b/internal/framework/pipeline/checkpoint.go @@ -28,14 +28,14 @@ type CheckpointRecorder interface { SourceSucceeded(moduleKey string, doc *source.SourceDocument) error SourceFailed(moduleKey string, err error) error ExtractRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - ExtractSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error + ExtractSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput) error ExtractFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error MergeRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - MergeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error + MergeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact) error MergeRejected(laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error MergeFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error NormalizeRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - NormalizeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error + NormalizeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact) error NormalizeRejected(laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error NormalizeFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error } @@ -45,14 +45,14 @@ type CheckpointRecorder interface { // remain available for callers that do not have step context. type StepCheckpointRecorder interface { ExtractRunningForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - ExtractSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error + ExtractSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput) error ExtractFailedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error MergeRunningForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - MergeSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error + MergeSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact) error MergeRejectedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error MergeFailedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error NormalizeRunningForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - NormalizeSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error + NormalizeSucceededForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact) error NormalizeRejectedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error NormalizeFailedForStep(stepID, laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error } @@ -293,16 +293,13 @@ type CheckpointDiagnostic struct { type ExtractCheckpoint struct { Outputs []CheckpointArtifact Rejected []contracts.RejectedOutput - Warnings []contracts.Warning } type MergeCheckpoint struct { - Output CheckpointArtifact - Warnings []contracts.Warning + Output CheckpointArtifact } type NormalizeCheckpoint struct { - Output CheckpointArtifact - Warnings []contracts.Warning + Output CheckpointArtifact } type CheckpointLoader interface { @@ -335,14 +332,14 @@ func (noopCheckpointRecorder) SourceFailed(string, error) error func (noopCheckpointRecorder) ExtractRunning(string, string, []CheckpointFingerprint) error { return nil } -func (noopCheckpointRecorder) ExtractSucceeded(string, string, []CheckpointFingerprint, []CheckpointArtifact, []contracts.RejectedOutput, []contracts.Warning) error { +func (noopCheckpointRecorder) ExtractSucceeded(string, string, []CheckpointFingerprint, []CheckpointArtifact, []contracts.RejectedOutput) error { return nil } func (noopCheckpointRecorder) ExtractFailed(string, string, []CheckpointFingerprint, error) error { return nil } func (noopCheckpointRecorder) MergeRunning(string, string, []CheckpointFingerprint) error { return nil } -func (noopCheckpointRecorder) MergeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact, []contracts.Warning) error { +func (noopCheckpointRecorder) MergeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact) error { return nil } func (noopCheckpointRecorder) MergeRejected(string, string, []CheckpointFingerprint, contracts.RejectedOutput) error { @@ -354,7 +351,7 @@ func (noopCheckpointRecorder) MergeFailed(string, string, []CheckpointFingerprin func (noopCheckpointRecorder) NormalizeRunning(string, string, []CheckpointFingerprint) error { return nil } -func (noopCheckpointRecorder) NormalizeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact, []contracts.Warning) error { +func (noopCheckpointRecorder) NormalizeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact) error { return nil } func (noopCheckpointRecorder) NormalizeRejected(string, string, []CheckpointFingerprint, contracts.RejectedOutput) error { @@ -387,11 +384,11 @@ func checkpointExtractRunning(recorder CheckpointRecorder, stepID, laneID, modul } return recorder.ExtractRunning(laneID, moduleKey, deps) } -func checkpointExtractSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { +func checkpointExtractSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { - return stepAware.ExtractSucceededForStep(stepID, laneID, moduleKey, deps, outputs, rejected, warnings) + return stepAware.ExtractSucceededForStep(stepID, laneID, moduleKey, deps, outputs, rejected) } - return recorder.ExtractSucceeded(laneID, moduleKey, deps, outputs, rejected, warnings) + return recorder.ExtractSucceeded(laneID, moduleKey, deps, outputs, rejected) } func checkpointExtractFailed(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, err error) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { @@ -405,11 +402,11 @@ func checkpointMergeRunning(recorder CheckpointRecorder, stepID, laneID, moduleK } return recorder.MergeRunning(laneID, moduleKey, deps) } -func checkpointMergeSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { +func checkpointMergeSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { - return stepAware.MergeSucceededForStep(stepID, laneID, moduleKey, deps, output, warnings) + return stepAware.MergeSucceededForStep(stepID, laneID, moduleKey, deps, output) } - return recorder.MergeSucceeded(laneID, moduleKey, deps, output, warnings) + return recorder.MergeSucceeded(laneID, moduleKey, deps, output) } func checkpointMergeRejected(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { @@ -429,11 +426,11 @@ func checkpointNormalizeRunning(recorder CheckpointRecorder, stepID, laneID, mod } return recorder.NormalizeRunning(laneID, moduleKey, deps) } -func checkpointNormalizeSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { +func checkpointNormalizeSucceeded(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { - return stepAware.NormalizeSucceededForStep(stepID, laneID, moduleKey, deps, output, warnings) + return stepAware.NormalizeSucceededForStep(stepID, laneID, moduleKey, deps, output) } - return recorder.NormalizeSucceeded(laneID, moduleKey, deps, output, warnings) + return recorder.NormalizeSucceeded(laneID, moduleKey, deps, output) } func checkpointNormalizeRejected(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { if stepAware, ok := recorder.(StepCheckpointRecorder); ok { diff --git a/internal/framework/pipeline/chunk_plan_store.go b/internal/framework/pipeline/chunk_plan_store.go index cc6a7c51..a9147964 100644 --- a/internal/framework/pipeline/chunk_plan_store.go +++ b/internal/framework/pipeline/chunk_plan_store.go @@ -24,7 +24,6 @@ type ChunkPlanRecord struct { PlanDigest string `json:"plan_digest"` Plan source.ChunkPlan `json:"plan"` Producer ChunkPlanProducer `json:"producer"` - Warnings []contracts.Warning `json:"warnings,omitempty"` Diagnostics []contracts.ProducerDiagnostic `json:"diagnostics,omitempty"` CreatedAt time.Time `json:"created_at"` } diff --git a/internal/framework/pipeline/debug.go b/internal/framework/pipeline/debug.go index f5fd1cbc..bb8ef355 100644 --- a/internal/framework/pipeline/debug.go +++ b/internal/framework/pipeline/debug.go @@ -52,11 +52,10 @@ type debugTimedEnvelope struct { } type debugBinaryEnvelope struct { - ContentBase64 string `json:"content_base64,omitempty"` - ContentDigest string `json:"content_digest,omitempty"` - MediaType string `json:"media_type,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - Warnings []contracts.Warning `json:"warnings,omitempty"` + ContentBase64 string `json:"content_base64,omitempty"` + ContentDigest string `json:"content_digest,omitempty"` + MediaType string `json:"media_type,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` } type debugSourceInput struct { @@ -483,14 +482,13 @@ func writeProducerTerminalDebug(recorder DebugRecorder, name string, terminal pr }) } -func debugContentEnvelope(content []byte, mediaType string, metadata map[string]any, warnings []contracts.Warning) debugBinaryEnvelope { +func debugContentEnvelope(content []byte, mediaType string, metadata map[string]any, _ any) debugBinaryEnvelope { content = redactSecretBytes(content) return debugBinaryEnvelope{ ContentBase64: base64.StdEncoding.EncodeToString(content), ContentDigest: debugContentDigest(content), MediaType: mediaType, Metadata: redactSensitiveMap(metadata), - Warnings: cloneWarnings(warnings), } } @@ -731,20 +729,9 @@ func debugValidationResultEnvelope(result contracts.ValidationResult) contracts. result.Message = string(redactSecretBytes([]byte(result.Message))) result.CorrectionGuidance = "" result.DiagnosticArtifactPath = string(redactSecretBytes([]byte(result.DiagnosticArtifactPath))) - for i := range result.Warnings { - result.Warnings[i].Message = string(redactSecretBytes([]byte(result.Warnings[i].Message))) - } return result } -func debugWarningEnvelopes(warnings []contracts.Warning) []contracts.Warning { - out := cloneWarnings(warnings) - for i := range out { - out[i].Message = string(redactSecretBytes([]byte(out[i].Message))) - } - return out -} - func debugRejectedOutputEnvelope(rejected contracts.RejectedOutput) contracts.RejectedOutput { rejected.Message = string(redactSecretBytes([]byte(rejected.Message))) rejected.DiagnosticArtifactPath = string(redactSecretBytes([]byte(rejected.DiagnosticArtifactPath))) diff --git a/internal/framework/pipeline/diagnostics.go b/internal/framework/pipeline/diagnostics.go index cdd8e7f8..bc2d6f63 100644 --- a/internal/framework/pipeline/diagnostics.go +++ b/internal/framework/pipeline/diagnostics.go @@ -107,20 +107,6 @@ func appendDiagnosticGroups(output *RunOutput, groups []contracts.DiagnosticGrou } cloned := contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: groups}).Groups output.diagnosticGroups = append(output.diagnosticGroups, cloned...) - seen := make(map[contracts.Warning]struct{}, len(output.Warnings)) - for _, warning := range output.Warnings { - seen[warning] = struct{}{} - } - for _, group := range cloned { - for _, sample := range group.Samples { - warning := contracts.Warning{Scope: sample.Scope, ReasonCode: group.ReasonCode, Message: sample.Message} - if _, exists := seen[warning]; exists { - continue - } - output.Warnings = append(output.Warnings, warning) - seen[warning] = struct{}{} - } - } } func finalizeDiagnostics(output *RunOutput) error { diff --git a/internal/framework/pipeline/diagnostics_test.go b/internal/framework/pipeline/diagnostics_test.go index 5762a0e2..3b895499 100644 --- a/internal/framework/pipeline/diagnostics_test.go +++ b/internal/framework/pipeline/diagnostics_test.go @@ -46,8 +46,8 @@ func TestTerminalDiagnosticGroupsPreserveValidatorOrigin(t *testing.T) { } } -func TestAppendDiagnosticGroupsProjectsOnlyMissingLegacyWarnings(t *testing.T) { - output := RunOutput{Warnings: []contracts.Warning{{Scope: "spells[0]", ReasonCode: "source_unrelated", Message: "not found"}}} +func TestAppendDiagnosticGroupsRetainsOnlyStructuredDiagnostics(t *testing.T) { + output := RunOutput{} appendDiagnosticGroups(&output, []contracts.DiagnosticGroup{{ Disposition: contracts.DiagnosticDispositionAdvisory, Category: contracts.DiagnosticCategoryDataQuality, @@ -56,9 +56,6 @@ func TestAppendDiagnosticGroupsProjectsOnlyMissingLegacyWarnings(t *testing.T) { OccurrenceCount: 2, Samples: []contracts.DiagnosticSample{{Scope: "spells[0]", Message: "not found"}, {Scope: "spells[1]", Message: "also not found"}}, }}) - if len(output.Warnings) != 2 { - t.Fatalf("legacy warnings = %#v, want one original and one projected record", output.Warnings) - } if err := finalizeDiagnostics(&output); err != nil { t.Fatalf("finalizeDiagnostics() error = %v", err) } diff --git a/internal/framework/pipeline/extractor_registry.go b/internal/framework/pipeline/extractor_registry.go index aaa3392a..0bcf74aa 100644 --- a/internal/framework/pipeline/extractor_registry.go +++ b/internal/framework/pipeline/extractor_registry.go @@ -71,7 +71,7 @@ func RegisterExtractorBuilder[T any](registry *ExtractorRegistry, spec ModuleSpe if err != nil { return erasedTypedResult{}, fmt.Errorf("clone extraction model candidate: %w", err) } - return erasedTypedResult{Value: result.Value, Warnings: cloneWarnings(result.Warnings), Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), ModelCandidate: candidate}, nil + return erasedTypedResult{Value: result.Value, Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), ModelCandidate: candidate}, nil }} if registry.typedEntries == nil { registry.typedEntries = map[string]typedExtractorEntry{} diff --git a/internal/framework/pipeline/merger_registry.go b/internal/framework/pipeline/merger_registry.go index ae20c7d5..29ca91a9 100644 --- a/internal/framework/pipeline/merger_registry.go +++ b/internal/framework/pipeline/merger_registry.go @@ -97,7 +97,7 @@ func RegisterMergerBuilder[T any](registry *MergerRegistry, spec ModuleSpec, val if err != nil { return erasedTypedResult{}, fmt.Errorf("clone merge model candidate: %w", err) } - return erasedTypedResult{Value: result.Value, Warnings: cloneWarnings(result.Warnings), Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), ModelCandidate: candidate}, nil + return erasedTypedResult{Value: result.Value, Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), ModelCandidate: candidate}, nil }, } return nil diff --git a/internal/framework/pipeline/normalizer_registry.go b/internal/framework/pipeline/normalizer_registry.go index 28c9d0c6..07d2653d 100644 --- a/internal/framework/pipeline/normalizer_registry.go +++ b/internal/framework/pipeline/normalizer_registry.go @@ -88,7 +88,7 @@ func RegisterNormalizerBuilder[T any](registry *NormalizerRegistry, spec ModuleS if err != nil { return erasedTypedResult{}, fmt.Errorf("clone normalize model candidate: %w", err) } - return erasedTypedResult{Value: result.Value, Warnings: cloneWarnings(result.Warnings), Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), Retry: cloneNormalizeRetry(result.Retry), ModelCandidate: candidate}, nil + return erasedTypedResult{Value: result.Value, Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), Retry: cloneNormalizeRetry(result.Retry), ModelCandidate: candidate}, nil }, } return nil @@ -101,7 +101,6 @@ func cloneNormalizeRetry(retry *contracts.NormalizeRetry) *contracts.NormalizeRe return &contracts.NormalizeRetry{ ReasonCode: retry.ReasonCode, Message: retry.Message, - FallbackWarnings: cloneWarnings(retry.FallbackWarnings), FallbackDiagnostics: contracts.CloneProducerDiagnostics(retry.FallbackDiagnostics), } } diff --git a/internal/framework/pipeline/normalizer_registry_test.go b/internal/framework/pipeline/normalizer_registry_test.go index 6d56b2b8..0ae79029 100644 --- a/internal/framework/pipeline/normalizer_registry_test.go +++ b/internal/framework/pipeline/normalizer_registry_test.go @@ -8,26 +8,23 @@ import ( ) type retryingNotesNormalizer struct { - warnings []contracts.Warning - retry *contracts.NormalizeRetry + retry *contracts.NormalizeRetry } func (retryingNotesNormalizer) Key() string { return "test/retry-normalize" } func (retryingNotesNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (n retryingNotesNormalizer) Normalize(_ context.Context, req contracts.TypedNormalizeRequest[codecNotes]) (contracts.TypedNormalizeResult[codecNotes], error) { - return contracts.TypedNormalizeResult[codecNotes]{Value: req.MergeOutput.Value, Warnings: n.warnings, Retry: n.retry}, nil + return contracts.TypedNormalizeResult[codecNotes]{Value: req.MergeOutput.Value, Retry: n.retry}, nil } func TestNormalizerRegistryErasureClonesRetryDirective(t *testing.T) { - warnings := []contracts.Warning{{Scope: "attempt", ReasonCode: "ordinary", Message: "ordinary warning"}} retry := &contracts.NormalizeRetry{ - ReasonCode: "retryable", - Message: "safe fallback available", - FallbackWarnings: []contracts.Warning{{Scope: "fallback", ReasonCode: "omitted", Message: "fallback warning"}}, + ReasonCode: "retryable", + Message: "safe fallback available", } registry := NewNormalizerRegistry() if err := RegisterNormalizer(registry, ModuleSpec{Key: "test/retry-normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes"}, func() (contracts.Normalizer[codecNotes], error) { - return retryingNotesNormalizer{warnings: warnings, retry: retry}, nil + return retryingNotesNormalizer{retry: retry}, nil }); err != nil { t.Fatalf("RegisterNormalizer() error = %v", err) } @@ -43,10 +40,8 @@ func TestNormalizerRegistryErasureClonesRetryDirective(t *testing.T) { if err != nil { t.Fatalf("normalize() error = %v", err) } - warnings[0].Message = "mutated" retry.Message = "mutated" - retry.FallbackWarnings[0].Message = "mutated" - if result.Retry == nil || result.Warnings[0].Message != "ordinary warning" || result.Retry.Message != "safe fallback available" || result.Retry.FallbackWarnings[0].Message != "fallback warning" { - t.Fatalf("erased retry result = %#v, want independent warning data", result) + if result.Retry == nil || result.Retry.Message != "safe fallback available" { + t.Fatalf("erased retry result = %#v, want independent retry data", result) } } diff --git a/internal/framework/pipeline/producer_attempts.go b/internal/framework/pipeline/producer_attempts.go index 115df497..62986eac 100644 --- a/internal/framework/pipeline/producer_attempts.go +++ b/internal/framework/pipeline/producer_attempts.go @@ -52,7 +52,6 @@ type producerAttemptRequest struct { // the current value as a safe fallback if its shared budget is exhausted. // Artifact-specific adapters are responsible for validating and populating it. type producerRetryDirective struct { - FallbackWarnings []contracts.Warning FallbackDiagnostics []contracts.ProducerDiagnostic } @@ -60,7 +59,7 @@ func (directive *producerRetryDirective) clone() *producerRetryDirective { if directive == nil { return nil } - return &producerRetryDirective{FallbackWarnings: cloneWarnings(directive.FallbackWarnings), FallbackDiagnostics: contracts.CloneProducerDiagnostics(directive.FallbackDiagnostics)} + return &producerRetryDirective{FallbackDiagnostics: contracts.CloneProducerDiagnostics(directive.FallbackDiagnostics)} } // producerAttemptOutput is intentionally artifact-neutral. Value remains @@ -69,7 +68,6 @@ func (directive *producerRetryDirective) clone() *producerRetryDirective { type producerAttemptOutput struct { Value any Candidate *contracts.ModelCandidate - Warnings []contracts.Warning Diagnostics []contracts.ProducerDiagnostic Retry *producerRetryDirective } @@ -82,7 +80,6 @@ func (output producerAttemptOutput) clone() (producerAttemptOutput, error) { return producerAttemptOutput{ Value: output.Value, Candidate: candidate, - Warnings: cloneWarnings(output.Warnings), Diagnostics: contracts.CloneProducerDiagnostics(output.Diagnostics), Retry: output.Retry.clone(), }, nil @@ -116,7 +113,6 @@ func (provenance producerAttemptProvenance) clone() producerAttemptProvenance { type producerAttemptTerminal struct { Action producerTerminalAction Value any - Warnings []contracts.Warning Diagnostics []contracts.ProducerDiagnostic Rejection *contracts.RejectedOutput Validation validationReport @@ -125,7 +121,6 @@ type producerAttemptTerminal struct { } func (terminal producerAttemptTerminal) clone() producerAttemptTerminal { - terminal.Warnings = cloneWarnings(terminal.Warnings) terminal.Diagnostics = contracts.CloneProducerDiagnostics(terminal.Diagnostics) if terminal.Rejection != nil { rejection := *terminal.Rejection @@ -210,7 +205,6 @@ func runProducerAttempts(ctx context.Context, config producerAttemptConfig, prod continue } if output.Retry != nil { - output.Warnings = append(output.Warnings, cloneWarnings(output.Retry.FallbackWarnings)...) output.Diagnostics = append(output.Diagnostics, contracts.CloneProducerDiagnostics(output.Retry.FallbackDiagnostics)...) } correctionCandidate, err := contracts.CloneModelCandidate(output.Candidate) @@ -259,14 +253,13 @@ func runProducerAttempts(ctx context.Context, config producerAttemptConfig, prod if incomplete := firstIncompleteValidation(report); incomplete != nil { provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptIncompleteAccepted, Validation: report}) if config.Policy.ValidatorFailure == ValidatorFailureWarnContinue { - warnings := terminalWarnings(output, report) - return producerAttemptTerminal{Action: producerTerminalIncompleteAccepted, Value: output.Value, Warnings: warnings, Diagnostics: cloneProducerDiagnostics(output.Diagnostics), Validation: report, ValidationIncomplete: true, Provenance: cloneProducerAttemptProvenance(provenance)}, nil + return producerAttemptTerminal{Action: producerTerminalIncompleteAccepted, Value: output.Value, Diagnostics: cloneProducerDiagnostics(output.Diagnostics), Validation: report, ValidationIncomplete: true, Provenance: cloneProducerAttemptProvenance(provenance)}, nil } return failedProducerAttempt(provenance), validatorFailureError(*incomplete) } provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptAccepted, Validation: report}) - return producerAttemptTerminal{Action: producerTerminalAccepted, Value: output.Value, Warnings: terminalWarnings(output, report), Diagnostics: cloneProducerDiagnostics(output.Diagnostics), Validation: report, Provenance: cloneProducerAttemptProvenance(provenance)}, nil + return producerAttemptTerminal{Action: producerTerminalAccepted, Value: output.Value, Diagnostics: cloneProducerDiagnostics(output.Diagnostics), Validation: report, Provenance: cloneProducerAttemptProvenance(provenance)}, nil } return failedProducerAttempt(provenance), errors.New("producer attempt budget was not exhausted deterministically") @@ -295,7 +288,7 @@ func applySemanticTerminalPolicy(policy ValidationPolicy, provenance []producerA rejected := contracts.RejectedOutput{ValidatorName: rejection.validatorName, ReasonCode: rejection.reasonCode, Message: rejection.message, AttemptCount: number, DiagnosticArtifactPath: rejection.diagnosticPath} switch policy.SemanticRejection { case SemanticRejectionRejectOutput: - return producerAttemptTerminal{Action: producerTerminalRejected, Warnings: terminalWarnings(output, report), Diagnostics: cloneProducerDiagnostics(output.Diagnostics), Rejection: &rejected, Validation: report, ValidationIncomplete: firstIncompleteValidation(report) != nil, Provenance: cloneProducerAttemptProvenance(provenance)}, nil + return producerAttemptTerminal{Action: producerTerminalRejected, Diagnostics: cloneProducerDiagnostics(output.Diagnostics), Rejection: &rejected, Validation: report, ValidationIncomplete: firstIncompleteValidation(report) != nil, Provenance: cloneProducerAttemptProvenance(provenance)}, nil case SemanticRejectionFailRun: return failedProducerAttempt(provenance), fmt.Errorf("producer candidate rejected after %d attempt(s): %s", number, rejection.message) default: @@ -313,12 +306,6 @@ func firstIncompleteValidation(report validationReport) *validationRecord { return nil } -func terminalWarnings(output producerAttemptOutput, report validationReport) []contracts.Warning { - warnings := cloneWarnings(output.Warnings) - warnings = append(warnings, report.Warnings()...) - return warnings -} - func cloneProducerDiagnostics(diagnostics []contracts.ProducerDiagnostic) []contracts.ProducerDiagnostic { return contracts.CloneProducerDiagnostics(diagnostics) } diff --git a/internal/framework/pipeline/producer_attempts_test.go b/internal/framework/pipeline/producer_attempts_test.go index 17b0fcf5..336fd930 100644 --- a/internal/framework/pipeline/producer_attempts_test.go +++ b/internal/framework/pipeline/producer_attempts_test.go @@ -234,27 +234,27 @@ func TestRunProducerAttemptsUsesModuleRetryBudgetAndFallback(t *testing.T) { }) t.Run("fallback", func(t *testing.T) { - fallbackWarning := contracts.Warning{ReasonCode: "fallback", Message: "fallback warning"} + fallbackDiagnostic := contracts.ProducerDiagnostic{Disposition: contracts.DiagnosticDispositionWarning, Category: contracts.DiagnosticCategoryFallback, ReasonCode: "fallback", OccurrenceCount: 1, Samples: []contracts.DiagnosticSample{{Scope: "fallback", Message: "fallback warning"}}} terminal, err := runProducerAttempts(context.Background(), producerAttemptConfig{Policy: DefaultValidationPolicy()}, func(context.Context, producerAttemptRequest) (producerAttemptOutput, error) { - return producerAttemptOutput{Value: "fallback", Retry: &producerRetryDirective{FallbackWarnings: []contracts.Warning{fallbackWarning}}}, nil + return producerAttemptOutput{Value: "fallback", Retry: &producerRetryDirective{FallbackDiagnostics: []contracts.ProducerDiagnostic{fallbackDiagnostic}}}, nil }, approveAttempt) if err != nil { t.Fatalf("runProducerAttempts() error = %v", err) } - if terminal.Action != producerTerminalAccepted || terminal.Value != "fallback" || !reflect.DeepEqual(terminal.Warnings, []contracts.Warning{fallbackWarning}) { + if terminal.Action != producerTerminalAccepted || terminal.Value != "fallback" || !reflect.DeepEqual(terminal.Diagnostics, []contracts.ProducerDiagnostic{fallbackDiagnostic}) { t.Fatalf("terminal = %#v", terminal) } }) } func TestRunProducerAttemptsRejectionWinsOverValidatorFailure(t *testing.T) { - firstWarnings := []contracts.Warning{{ReasonCode: "discarded", Message: "discarded warning"}} - secondWarnings := []contracts.Warning{{ReasonCode: "accepted", Message: "accepted warning"}} + firstDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("discarded", "discarded warning")} + secondDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted warning")} terminal, err := runProducerAttempts(context.Background(), producerAttemptConfig{Retries: 1, Policy: DefaultValidationPolicy()}, func(_ context.Context, request producerAttemptRequest) (producerAttemptOutput, error) { if request.Number == 1 { - return producerAttemptOutput{Value: "first", Candidate: attemptCandidate(t, "defective"), Warnings: firstWarnings}, nil + return producerAttemptOutput{Value: "first", Candidate: attemptCandidate(t, "defective"), Diagnostics: firstDiagnostics}, nil } - return producerAttemptOutput{Value: "second", Candidate: attemptCandidate(t, "corrected"), Warnings: secondWarnings}, nil + return producerAttemptOutput{Value: "second", Candidate: attemptCandidate(t, "corrected"), Diagnostics: secondDiagnostics}, nil }, func(_ context.Context, output producerAttemptOutput) (validationReport, error) { if output.Value == "first" { report := rejectedAttemptReport("defect") @@ -272,8 +272,8 @@ func TestRunProducerAttemptsRejectionWinsOverValidatorFailure(t *testing.T) { if got := attemptKinds(terminal.Provenance); !reflect.DeepEqual(got, []producerAttemptKind{producerAttemptInitial, producerAttemptSemanticRetry}) { t.Fatalf("attempt kinds = %v", got) } - if got := terminal.Warnings; !reflect.DeepEqual(got, secondWarnings) { - t.Fatalf("terminal warnings = %#v, want %#v", got, secondWarnings) + if got := terminal.Diagnostics; !reflect.DeepEqual(got, secondDiagnostics) { + t.Fatalf("terminal diagnostics = %#v, want %#v", got, secondDiagnostics) } if len(terminal.Provenance[0].Validation.records) != 2 { t.Fatalf("first validation records = %#v, want rejection and failure", terminal.Provenance[0].Validation.records) @@ -302,9 +302,6 @@ func TestValidationSummaryIsBoundedAndCorrectedSuccessIsQuiet(t *testing.T) { if len(summary.ReasonCodes) != 0 || len(summary.RejectingValidators) != 0 || len(summary.IncompleteValidators) != 0 { t.Fatalf("corrected success summary retained prior findings: %#v", summary) } - if len(terminal.Warnings) != 0 { - t.Fatalf("corrected success warnings = %#v, want none", terminal.Warnings) - } } func TestWarnContinueRecordsOneWarningForEachExhaustedValidator(t *testing.T) { @@ -324,9 +321,6 @@ func TestWarnContinueRecordsOneWarningForEachExhaustedValidator(t *testing.T) { if terminal.Action != producerTerminalIncompleteAccepted { t.Fatalf("terminal action = %q", terminal.Action) } - if len(terminal.Warnings) != 0 { - t.Fatalf("warnings = %#v, want structured diagnostics only", terminal.Warnings) - } groups, groupErr := terminalDiagnosticGroups(terminal, contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageNormalize, StepID: "step", LaneID: "lane", ModuleKey: "module"}, nil) if groupErr != nil { t.Fatalf("terminalDiagnosticGroups() error = %v", groupErr) diff --git a/internal/framework/pipeline/references.go b/internal/framework/pipeline/references.go index 7cb6646f..1f543df3 100644 --- a/internal/framework/pipeline/references.go +++ b/internal/framework/pipeline/references.go @@ -30,45 +30,45 @@ type ReferenceMaterializationOptions struct { WorkingDir string } -func MaterializeReferences(resolved ResolvedPipeline, catalog ModuleCatalog, options ReferenceMaterializationOptions) (ResolvedPipeline, []contracts.Warning, error) { +func MaterializeReferences(resolved ResolvedPipeline, catalog ModuleCatalog, options ReferenceMaterializationOptions) (ResolvedPipeline, []contracts.ProducerDiagnostic, error) { out := resolved out.ChunkReferences = CloneReferenceTarget(resolved.ChunkReferences) - chunkReferenceSet, chunkWarnings, err := materializeReferenceTarget(resolved.ID, resolved.ChunkReferences, "", catalog, options) + chunkReferenceSet, chunkDiagnostics, err := materializeReferenceTarget(resolved.ID, resolved.ChunkReferences, "", catalog, options) if err != nil { return ResolvedPipeline{}, nil, err } out.ChunkReferences.ReferenceSet = chunkReferenceSet - warnings := append([]contracts.Warning(nil), chunkWarnings...) + diagnostics := contracts.CloneProducerDiagnostics(chunkDiagnostics) if len(resolved.Steps) == 0 { - return out, warnings, nil + return out, diagnostics, nil } - materializeLane := func(lane ResolvedArtifactLane) (ResolvedArtifactLane, []contracts.Warning, error) { + materializeLane := func(lane ResolvedArtifactLane) (ResolvedArtifactLane, []contracts.ProducerDiagnostic, error) { materializedLane := lane - var allWarnings []contracts.Warning + var allDiagnostics []contracts.ProducerDiagnostic materializedLane.ExtractReferences = CloneReferenceTarget(lane.ExtractReferences) materializedLane.MergeReferences = CloneReferenceTarget(lane.MergeReferences) materializedLane.NormalizeReferences = CloneReferenceTarget(lane.NormalizeReferences) - extractReferenceSet, laneWarnings, err := materializeReferenceTarget(resolved.ID, lane.ExtractReferences, lane.ArtifactKind, catalog, options) + extractReferenceSet, laneDiagnostics, err := materializeReferenceTarget(resolved.ID, lane.ExtractReferences, lane.ArtifactKind, catalog, options) if err != nil { return ResolvedArtifactLane{}, nil, err } materializedLane.ExtractReferences.ReferenceSet = extractReferenceSet - allWarnings = append(allWarnings, laneWarnings...) + allDiagnostics = append(allDiagnostics, laneDiagnostics...) - mergeReferenceSet, laneWarnings, err := materializeReferenceTarget(resolved.ID, lane.MergeReferences, lane.ArtifactKind, catalog, options) + mergeReferenceSet, laneDiagnostics, err := materializeReferenceTarget(resolved.ID, lane.MergeReferences, lane.ArtifactKind, catalog, options) if err != nil { return ResolvedArtifactLane{}, nil, err } materializedLane.MergeReferences.ReferenceSet = mergeReferenceSet - allWarnings = append(allWarnings, laneWarnings...) + allDiagnostics = append(allDiagnostics, laneDiagnostics...) - normalizeReferenceSet, laneWarnings, err := materializeReferenceTarget(resolved.ID, lane.NormalizeReferences, lane.ArtifactKind, catalog, options) + normalizeReferenceSet, laneDiagnostics, err := materializeReferenceTarget(resolved.ID, lane.NormalizeReferences, lane.ArtifactKind, catalog, options) if err != nil { return ResolvedArtifactLane{}, nil, err } materializedLane.NormalizeReferences.ReferenceSet = normalizeReferenceSet - allWarnings = append(allWarnings, laneWarnings...) - return materializedLane, allWarnings, nil + allDiagnostics = append(allDiagnostics, laneDiagnostics...) + return materializedLane, allDiagnostics, nil } if len(resolved.Steps) > 0 { out.Steps = make([]ResolvedPipelineStep, len(resolved.Steps)) @@ -76,36 +76,16 @@ func MaterializeReferences(resolved ResolvedPipeline, catalog ModuleCatalog, opt out.Steps[i].ID = step.ID out.Steps[i].ArtifactLanes = make([]ResolvedArtifactLane, len(step.ArtifactLanes)) for j, lane := range step.ArtifactLanes { - materializedLane, laneWarnings, err := materializeLane(lane) + materializedLane, laneDiagnostics, err := materializeLane(lane) if err != nil { return ResolvedPipeline{}, nil, err } out.Steps[i].ArtifactLanes[j] = materializedLane - warnings = append(warnings, laneWarnings...) + diagnostics = append(diagnostics, laneDiagnostics...) } } } - return out, warnings, nil -} - -// ReferenceDiagnostics converts framework-owned empty-reference findings into -// structured process diagnostics. Legacy callers may continue to use the -// materializer's warning return while the framework migration is in progress. -func ReferenceDiagnostics(warnings []contracts.Warning) []contracts.ProducerDiagnostic { - diagnostics := make([]contracts.ProducerDiagnostic, 0, len(warnings)) - for _, warning := range warnings { - if warning.ReasonCode != "empty_reference" { - continue - } - diagnostics = append(diagnostics, contracts.ProducerDiagnostic{ - Disposition: contracts.DiagnosticDispositionWarning, - Category: contracts.DiagnosticCategoryConfiguration, - ReasonCode: "empty_reference", - OccurrenceCount: 1, - Samples: []contracts.DiagnosticSample{{Scope: warning.Scope, Message: warning.Message}}, - }) - } - return contracts.CloneProducerDiagnostics(diagnostics) + return out, diagnostics, nil } func materializeReferenceTarget( @@ -114,7 +94,7 @@ func materializeReferenceTarget( artifactKind contracts.ArtifactKind, catalog ModuleCatalog, options ReferenceMaterializationOptions, -) (contracts.ReferenceSet, []contracts.Warning, error) { +) (contracts.ReferenceSet, []contracts.ProducerDiagnostic, error) { if len(target.Bindings) == 0 { return contracts.ReferenceSet{}, nil, nil } @@ -129,7 +109,7 @@ func materializeReferenceTarget( } set := contracts.ReferenceSet{Slots: make(map[string]contracts.ResolvedReferenceSlot, len(target.Bindings))} - var warnings []contracts.Warning + var diagnostics []contracts.ProducerDiagnostic for _, binding := range target.Bindings { slotName := strings.TrimSpace(binding.SlotName) slot, ok := slotByName[slotName] @@ -179,11 +159,7 @@ func materializeReferenceTarget( return contracts.ReferenceSet{}, nil, fmt.Errorf("%s reference slot %q path %q media type %q is not accepted", referenceTargetContext(pipelineID, target), slotName, path, mediaType) } if len(content) == 0 { - warnings = append(warnings, contracts.Warning{ - Scope: referenceWarningScope(pipelineID, target, slotName), - ReasonCode: "empty_reference", - Message: fmt.Sprintf("reference slot %q for %s is bound to an empty file", slotName, referenceTargetLabel(target)), - }) + diagnostics = append(diagnostics, contracts.ProducerDiagnostic{Disposition: contracts.DiagnosticDispositionWarning, Category: contracts.DiagnosticCategoryConfiguration, ReasonCode: "empty_reference", OccurrenceCount: 1, Samples: []contracts.DiagnosticSample{{Scope: referenceWarningScope(pipelineID, target, slotName), Message: fmt.Sprintf("reference slot %q for %s is bound to an empty file", slotName, referenceTargetLabel(target))}}}) } item := contracts.ReferenceItem{ @@ -200,7 +176,7 @@ func materializeReferenceTarget( Items: []contracts.ReferenceItem{item}, } } - return set, warnings, nil + return set, diagnostics, nil } type referenceSizeLimitError struct { diff --git a/internal/framework/pipeline/references_test.go b/internal/framework/pipeline/references_test.go index 85b622bf..a3054a7b 100644 --- a/internal/framework/pipeline/references_test.go +++ b/internal/framework/pipeline/references_test.go @@ -445,17 +445,13 @@ func TestMaterializeReferencesWarnsForEmptyFiles(t *testing.T) { writeReferenceFile(t, path, nil) resolved := resolvedPipelineWithReference(t, "roster", "empty.txt", contracts.ReferenceBindingSourceConfig, contracts.ReferenceSlot{Name: "roster"}) - materialized, warnings, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{{Name: "roster"}}), ReferenceMaterializationOptions{ + materialized, diagnostics, err := MaterializeReferences(resolved, referenceCatalog(t, []contracts.ReferenceSlot{{Name: "roster"}}), ReferenceMaterializationOptions{ ConfigPath: filepath.Join(configDir, "config.yml"), }) if err != nil { t.Fatalf("MaterializeReferences() error = %v, want nil", err) } - if len(warnings) != 1 || warnings[0].ReasonCode != "empty_reference" { - t.Fatalf("warnings = %#v, want empty reference warning", warnings) - } - diagnostics := ReferenceDiagnostics(warnings) - if len(diagnostics) != 1 || diagnostics[0].Disposition != contracts.DiagnosticDispositionWarning || diagnostics[0].Category != contracts.DiagnosticCategoryConfiguration || diagnostics[0].ReasonCode != "empty_reference" || diagnostics[0].OccurrenceCount != 1 || !reflect.DeepEqual(diagnostics[0].Samples, []contracts.DiagnosticSample{{Scope: warnings[0].Scope, Message: warnings[0].Message}}) { + if len(diagnostics) != 1 || diagnostics[0].Disposition != contracts.DiagnosticDispositionWarning || diagnostics[0].Category != contracts.DiagnosticCategoryConfiguration || diagnostics[0].ReasonCode != "empty_reference" || diagnostics[0].OccurrenceCount != 1 || len(diagnostics[0].Samples) != 1 { t.Fatalf("diagnostics = %#v, want structured empty-reference signal", diagnostics) } item := materialized.Steps[0].ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0] @@ -487,13 +483,13 @@ func TestMaterializeReferencesWarningScopesIncludeTargetContext(t *testing.T) { t.Fatalf("ResolvePipeline() error = %v, want nil", err) } - _, warnings, err := MaterializeReferences(resolved, catalog, ReferenceMaterializationOptions{ + _, diagnostics, err := MaterializeReferences(resolved, catalog, ReferenceMaterializationOptions{ ConfigPath: filepath.Join(configDir, "config.yml"), }) if err != nil { t.Fatalf("MaterializeReferences() error = %v, want nil", err) } - got := warningScopes(warnings) + got := diagnosticScopes(diagnostics) want := []string{ "pipeline.baseline.chunk.reference.scene_guide", "pipeline.baseline.lane.events.extract.reference.roster", @@ -625,10 +621,10 @@ func referenceCatalogForTargets(t *testing.T, chunkSlots, extractSlots, mergeSlo ) } -func warningScopes(warnings []contracts.Warning) []string { - scopes := make([]string, 0, len(warnings)) - for _, warning := range warnings { - scopes = append(scopes, warning.Scope) +func diagnosticScopes(diagnostics []contracts.ProducerDiagnostic) []string { + scopes := make([]string, 0, len(diagnostics)) + for _, diagnostic := range diagnostics { + scopes = append(scopes, diagnostic.Samples[0].Scope) } sort.Strings(scopes) return scopes diff --git a/internal/framework/pipeline/runner.go b/internal/framework/pipeline/runner.go index d2a6ed5b..1343cbc6 100644 --- a/internal/framework/pipeline/runner.go +++ b/internal/framework/pipeline/runner.go @@ -50,7 +50,6 @@ type RunInput struct { StartedAt time.Time LLMProfiles []artifacts.LLMProfileManifest Metadata map[string]any - Warnings []contracts.Warning Diagnostics []contracts.ProducerDiagnostic ChunkCacheMode ChunkCacheMode ChunkPlans ChunkPlanStore @@ -74,7 +73,6 @@ type RunOutput struct { ChunkPlan *artifacts.ChunkPlanSummary `json:"chunk_plan,omitempty"` NormalizeOutputs []contracts.SerializedOutput `json:"normalize_outputs,omitempty"` Rejected []contracts.RejectedOutput `json:"rejected,omitempty"` - Warnings []contracts.Warning `json:"warnings,omitempty"` Diagnostics contracts.DiagnosticCollection `json:"diagnostics,omitempty"` OutputFiles []contracts.OutputFile `json:"-"` CheckpointEvents []CheckpointEvent `json:"checkpoint_events,omitempty"` @@ -132,7 +130,6 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err defer func() { output.Manifest.LLMProfiles = mergeLLMProfileManifests(input.LLMProfiles, llmProfileManifests(input.llmClient)) }() - output.Warnings = append(output.Warnings, cloneWarnings(input.Warnings)...) inputDiagnostics, diagnosticErr := promoteProducerDiagnostics(input.Diagnostics, contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageReferences}, nil) if diagnosticErr != nil { return failOutput(output), fmt.Errorf("promote input diagnostics: %w", diagnosticErr) @@ -256,14 +253,12 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err if chunkResult.validation != nil { output.ValidationSummaries = append(output.ValidationSummaries, artifacts.CloneValidationSummary(*chunkResult.validation)) } - output.Warnings = append(output.Warnings, chunkResult.warnings...) appendDiagnosticGroups(&output, chunkResult.diagnostics) chunkDebugPayload := map[string]any{ "cache_mode": chunkMode, "lookup": chunkResult.lookup, "accepted": chunkResult.accepted, "materialized_chunks": debugSourceChunkEnvelopes(chunkResult.chunks), - "warnings": chunkResult.warnings, } if chunkResult.plan != nil { chunkDebugPayload["plan"] = debugChunkPlanEnvelope(*chunkResult.plan) @@ -356,7 +351,6 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err "manifest": output.Manifest, "normalize_outputs": debugSerializedOutputEnvelopes(output.NormalizeOutputs), "rejected": debugRejectedOutputEnvelopes(output.Rejected), - "warnings": output.Warnings, "options": redactSensitiveMap(input.pipeline.Output.Options), "metadata": redactSensitiveMap(input.Metadata), } @@ -385,7 +379,6 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err Manifest: output.Manifest, NormalizeOutputs: cloneSerializedOutputs(output.NormalizeOutputs), Rejected: cloneRejectedOutputs(output.Rejected), - Warnings: output.Warnings, Diagnostics: contracts.CloneDiagnosticCollection(output.Diagnostics), LLMProfile: input.pipeline.Output.LLMProfile, StructuredOutputRepairAttempts: cloneStructuredOutputRepairAttempts(input.pipeline.Output.StructuredOutputRepairAttempts), @@ -455,7 +448,6 @@ func (r *Runner) runPreparedSteps(ctx context.Context, input RunInput, checkpoin type retryAttemptResult struct { accepted bool rejection *contracts.RejectedOutput - warnings []contracts.Warning } func runSimpleRetry(ctx context.Context, retries int, run func(attempt int) (retryAttemptResult, error)) (retryAttemptResult, error) { @@ -485,7 +477,7 @@ func runSimpleRetry(ctx context.Context, retries int, run func(attempt int) (ret if result.rejection != nil { rejection := *result.rejection rejection.AttemptCount = attempt - last = retryAttemptResult{rejection: &rejection, warnings: cloneWarnings(result.warnings)} + last = retryAttemptResult{rejection: &rejection} } if ctxErr := ctx.Err(); ctxErr != nil { return retryAttemptResult{}, ctxErr @@ -708,6 +700,10 @@ func validatorChainManifests(chains []ResolvedValidatorChain) []artifacts.Valida } func failOutput(output RunOutput) RunOutput { + // Preserve any diagnostics already accepted by the pipeline when a later + // operation fails. Producers and validators validate each diagnostic before + // it is appended, so finalization here cannot introduce a new failure path. + _ = finalizeDiagnostics(&output) if output.Manifest.PipelineID != "" { populateOutputManifest(&output) output.Manifest.ValidationStatus = "failed" @@ -982,13 +978,6 @@ func manifestMetadataWithSessionID(metadata map[string]any, sessionID string) (m return out, nil } -func cloneWarnings(warnings []contracts.Warning) []contracts.Warning { - if len(warnings) == 0 { - return nil - } - return append([]contracts.Warning(nil), warnings...) -} - func cloneSourceChunkPtr(chunk *source.Chunk) (*source.Chunk, error) { if chunk == nil { return nil, nil diff --git a/internal/framework/pipeline/runner_accepted_checkpoint_test.go b/internal/framework/pipeline/runner_accepted_checkpoint_test.go index 4fbe74c6..f177b1c7 100644 --- a/internal/framework/pipeline/runner_accepted_checkpoint_test.go +++ b/internal/framework/pipeline/runner_accepted_checkpoint_test.go @@ -84,7 +84,8 @@ func TestRunnerHydratesRequiredNormalizedArtifact(t *testing.T) { loader := newAcceptedCheckpointLoader() producerKey := CheckpointLaneKey(producer.resolved.StepID, producer.resolved.ID) consumerKey := CheckpointLaneKey(consumer.resolved.StepID, consumer.resolved.ID) - loader.accepted[producerKey] = NormalizeCheckpoint{Output: stored, Warnings: []contracts.Warning{{Scope: "normalize", ReasonCode: "stored-warning", Message: "stored normalize warning"}}} + stored.Diagnostics = []CheckpointDiagnostic{{Diagnostic: contracts.ProducerDiagnostic{Disposition: contracts.DiagnosticDispositionWarning, Category: contracts.DiagnosticCategoryFallback, ReasonCode: "stored-warning", OccurrenceCount: 1, Samples: []contracts.DiagnosticSample{{Scope: "normalize", Message: "stored normalize warning"}}}}} + loader.accepted[producerKey] = NormalizeCheckpoint{Output: stored} loader.acceptedDecision[producerKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused) policy := CheckpointExecutionPolicy{ RequireReusableLanes: map[string]struct{}{producerKey: {}}, @@ -101,8 +102,8 @@ func TestRunnerHydratesRequiredNormalizedArtifact(t *testing.T) { if string(item.Content) != string(stored.Artifact.Content) || item.Producer.StepID != producer.resolved.StepID || item.Producer.LaneID != producer.resolved.ID { t.Fatalf("consumer generated reference = %#v, want exact hydrated producer bytes and identity", item) } - if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != "stored-warning" { - t.Fatalf("hydrated warnings = %#v, want normalize checkpoint warnings only", output.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != "stored-warning" { + t.Fatalf("hydrated diagnostics = %#v, want normalize checkpoint diagnostics only", output.Diagnostics) } assertAcceptedNormalizeEvent(t, output.CheckpointEvents, producer.resolved.StepID, producer.resolved.ID, CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused) for _, event := range output.CheckpointEvents { @@ -225,7 +226,8 @@ func TestRunnerRetainsEarlierHydratedProducerWhenLaterRequiredProducerFails(t *t loader := newAcceptedCheckpointLoader() firstKey := CheckpointLaneKey(first.resolved.StepID, first.resolved.ID) secondKey := CheckpointLaneKey(second.resolved.StepID, second.resolved.ID) - loader.accepted[firstKey] = NormalizeCheckpoint{Output: stored, Warnings: []contracts.Warning{{Scope: "normalize", ReasonCode: "retained-warning", Message: "retained warning"}}} + stored.Diagnostics = []CheckpointDiagnostic{{Diagnostic: contracts.ProducerDiagnostic{Disposition: contracts.DiagnosticDispositionWarning, Category: contracts.DiagnosticCategoryFallback, ReasonCode: "retained-warning", OccurrenceCount: 1, Samples: []contracts.DiagnosticSample{{Scope: "normalize", Message: "retained warning"}}}}} + loader.accepted[firstKey] = NormalizeCheckpoint{Output: stored} loader.acceptedDecision[firstKey] = NewCheckpointDecision(CheckpointDecisionReused, CheckpointReasonAcceptedArtifactReused) loader.acceptedDecision[secondKey] = NewCheckpointDecision(CheckpointDecisionExecuted, CheckpointReasonMissing) policy := CheckpointExecutionPolicy{RequireReusableLanes: map[string]struct{}{firstKey: {}, secondKey: {}}} @@ -240,8 +242,8 @@ func TestRunnerRetainsEarlierHydratedProducerWhenLaterRequiredProducerFails(t *t if len(output.NormalizeOutputs) != 1 || output.NormalizeOutputs[0].LaneID != first.resolved.ID || string(output.NormalizeOutputs[0].Artifact.Content) != string(stored.Artifact.Content) { t.Fatalf("retained normalize outputs = %#v, want first producer", output.NormalizeOutputs) } - if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != "retained-warning" { - t.Fatalf("retained warnings = %#v", output.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != "retained-warning" { + t.Fatalf("retained diagnostics = %#v", output.Diagnostics) } type decisionExpectation struct { step, lane string diff --git a/internal/framework/pipeline/runner_attempt_debug_test.go b/internal/framework/pipeline/runner_attempt_debug_test.go index 71bd9929..3580feec 100644 --- a/internal/framework/pipeline/runner_attempt_debug_test.go +++ b/internal/framework/pipeline/runner_attempt_debug_test.go @@ -162,13 +162,13 @@ func TestRunnerWritesAttemptScopedMergeAndNormalizeDebug(t *testing.T) { if err := callAttemptDebugLLM(ctx, client, "merge"); err != nil { return erasedTypedResult{}, err } - return erasedTypedResult{Value: codecNotes{Items: []string{"merged"}}, Warnings: []contracts.Warning{{Scope: "merge", ReasonCode: "observed", Message: "merge warning"}}}, nil + return erasedTypedResult{Value: codecNotes{Items: []string{"merged"}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("merge", "merge diagnostic")}}, nil } prepared.Steps[0].lanes[0].typed.normalize = func(ctx context.Context, _ any, _ contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) { if err := callAttemptDebugLLM(ctx, client, "normalize"); err != nil { return erasedTypedResult{}, err } - return erasedTypedResult{Value: codecNotes{Items: []string{"normalized"}}, Warnings: []contracts.Warning{{Scope: "normalize", ReasonCode: "observed", Message: "normalize warning"}}}, nil + return erasedTypedResult{Value: codecNotes{Items: []string{"normalized"}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("normalize", "normalize diagnostic")}}, nil } output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Debug: debug}) @@ -213,7 +213,7 @@ func TestRunnerWritesAttemptScopedMergeAndNormalizeDebug(t *testing.T) { } } -func TestRunnerRecordsDistinctRetryAttemptsAndPromotesAcceptedWarningsOnly(t *testing.T) { +func TestRunnerRecordsDistinctRetryAttemptsAndPromotesAcceptedDiagnosticsOnly(t *testing.T) { for _, stage := range []ModuleStage{StageMerge, StageNormalize} { t.Run(string(stage), func(t *testing.T) { prepared := preparedAttemptDebugPipeline(t) @@ -230,7 +230,7 @@ func TestRunnerRecordsDistinctRetryAttemptsAndPromotesAcceptedWarningsOnly(t *te if attempts == 1 { scope = "discarded" } - return erasedTypedResult{Value: codecNotes{Items: []string{scope}}, Warnings: []contracts.Warning{{Scope: scope, ReasonCode: "observed", Message: scope}}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%s"]}`, scope))}, nil + return erasedTypedResult{Value: codecNotes{Items: []string{scope}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(scope, scope)}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%s"]}`, scope))}, nil } validatorCalls := 0 validator := preparedValidator{ @@ -268,11 +268,11 @@ func TestRunnerRecordsDistinctRetryAttemptsAndPromotesAcceptedWarningsOnly(t *te if len(first.LLMCalls) != 1 || len(second.LLMCalls) != 1 || first.LLMCalls[0].CallID == second.LLMCalls[0].CallID { t.Fatalf("retry LLM calls = first %#v, second %#v; want distinct calls", first.LLMCalls, second.LLMCalls) } - if !strings.Contains(string(debug.json[firstPath]), "discarded") || !strings.Contains(string(debug.json[firstPath]), "rejection") { - t.Fatalf("first attempt envelope = %s, want discarded warning and rejection", debug.json[firstPath]) + if !strings.Contains(string(debug.json[firstPath]), "rejection") { + t.Fatalf("first attempt envelope = %s, want rejection", debug.json[firstPath]) } - if len(output.Warnings) != 1 || output.Warnings[0].Scope != "accepted" { - t.Fatalf("promoted warnings = %#v, want accepted attempt only", output.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].Samples[0].Scope != "accepted" { + t.Fatalf("promoted diagnostics = %#v, want accepted attempt only", output.Diagnostics) } }) } diff --git a/internal/framework/pipeline/runner_candidate_encoding_test.go b/internal/framework/pipeline/runner_candidate_encoding_test.go index 7e907c52..a33b8037 100644 --- a/internal/framework/pipeline/runner_candidate_encoding_test.go +++ b/internal/framework/pipeline/runner_candidate_encoding_test.go @@ -101,13 +101,13 @@ type candidateCheckpointRecorder struct { normalizeOutput CheckpointArtifact } -func (r *candidateCheckpointRecorder) MergeSucceeded(_ string, _ string, _ []CheckpointFingerprint, output CheckpointArtifact, _ []contracts.Warning) error { +func (r *candidateCheckpointRecorder) MergeSucceeded(_ string, _ string, _ []CheckpointFingerprint, output CheckpointArtifact) error { r.mergeSucceeded++ r.mergeOutput = cloneCheckpointArtifact(output) return nil } -func (r *candidateCheckpointRecorder) NormalizeSucceeded(_ string, _ string, _ []CheckpointFingerprint, output CheckpointArtifact, _ []contracts.Warning) error { +func (r *candidateCheckpointRecorder) NormalizeSucceeded(_ string, _ string, _ []CheckpointFingerprint, output CheckpointArtifact) error { r.normalizeSucceeded++ r.normalizeOutput = cloneCheckpointArtifact(output) return nil diff --git a/internal/framework/pipeline/runner_chunk_plan.go b/internal/framework/pipeline/runner_chunk_plan.go index 836c7d80..4437e436 100644 --- a/internal/framework/pipeline/runner_chunk_plan.go +++ b/internal/framework/pipeline/runner_chunk_plan.go @@ -15,7 +15,6 @@ type chunkPlanExecution struct { accepted bool chunks []source.Chunk plan *source.ChunkPlan - warnings []contracts.Warning diagnostics []contracts.DiagnosticGroup rejection *contracts.RejectedOutput lookup ChunkPlanDecision @@ -29,7 +28,6 @@ type generatedChunkPlanCandidate struct { plan source.ChunkPlan chunks []source.Chunk record ChunkPlanRecord - producerWarnings []contracts.Warning producerDiagnostics []contracts.ProducerDiagnostic terminal *attemptTerminalRecorder } @@ -71,7 +69,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S if validationErr == nil { report, err := r.validateChunkReport(ctx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.Metadata, input.Prepared.chunkValidators, 1, input.Debug) if err != nil { - result.setValidation(report.Warnings(), nil, err) + result.setValidation(nil, err) return result, err } rejection := report.FirstRejection() @@ -82,9 +80,8 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S } result.plan = &plan result.chunks = chunks - result.warnings = append(cloneWarnings(record.Warnings), report.Warnings()...) result.accepted = true - result.setValidation(report.Warnings(), nil, nil) + result.setValidation(nil, nil) cachedTerminal := producerAttemptTerminal{Action: producerTerminalAccepted, Diagnostics: contracts.CloneProducerDiagnostics(record.Diagnostics), Validation: report} diagnostics, diagnosticErr := terminalDiagnosticGroups(cachedTerminal, contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageChunk, ModuleKey: chunker.Key()}, nil) if diagnosticErr != nil { @@ -97,7 +94,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S } if incomplete != nil && input.pipeline.ChunkValidationPolicy.ValidatorFailure == ValidatorFailureFailRun { failure := validatorFailureError(*incomplete) - result.setValidation(report.Warnings(), nil, failure) + result.setValidation(nil, failure) failedTerminal := producerAttemptTerminal{Action: producerTerminalFailed, Validation: report, ValidationIncomplete: true} summary := validationSummary(failedTerminal, StageChunk, "", "", chunker.Key(), "", 0) result.validation = &summary @@ -145,7 +142,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S plan, chunks, validationErr := validateAndMaterializeChunkPlan(doc, chunkResult.Plan) if validationErr != nil { attemptErr := fmt.Errorf("validate chunk plan from chunker %q: %w", chunker.Key(), validationErr) - payload := map[string]any{"plan": debugChunkPlanEnvelope(chunkResult.Plan), "warnings": debugWarningEnvelopes(chunkResult.Warnings)} + payload := map[string]any{"plan": debugChunkPlanEnvelope(chunkResult.Plan)} return producerAttemptOutput{}, attemptTerminal.record(payload, fmt.Errorf("%w: %v", contracts.ErrInvalidStructuredOutput, attemptErr)) } planDigest, digestErr := source.DigestChunkPlan(plan) @@ -168,19 +165,18 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S References: append([]artifacts.ReferenceProvenance(nil), referenceTargetProvenance(input.pipeline.ChunkReferences)...), Metadata: producerMetadata, }, - Warnings: cloneWarnings(chunkResult.Warnings), CreatedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC(), } - return producerAttemptOutput{Value: generatedChunkPlanCandidate{plan: plan, chunks: chunks, record: candidate, producerWarnings: cloneWarnings(chunkResult.Warnings), producerDiagnostics: contracts.CloneProducerDiagnostics(chunkResult.Diagnostics), terminal: &attemptTerminal}, Candidate: chunkResult.ModelCandidate, Warnings: cloneWarnings(chunkResult.Warnings), Diagnostics: contracts.CloneProducerDiagnostics(chunkResult.Diagnostics)}, nil + return producerAttemptOutput{Value: generatedChunkPlanCandidate{plan: plan, chunks: chunks, record: candidate, producerDiagnostics: contracts.CloneProducerDiagnostics(chunkResult.Diagnostics), terminal: &attemptTerminal}, Candidate: chunkResult.ModelCandidate, Diagnostics: contracts.CloneProducerDiagnostics(chunkResult.Diagnostics)}, nil }, func(validationCtx context.Context, output producerAttemptOutput) (validationReport, error) { candidate, ok := output.Value.(generatedChunkPlanCandidate) if !ok { return validationReport{}, fmt.Errorf("chunk attempt candidate has incompatible type") } report, validationErr := r.validateChunkReport(validationCtx, doc, chunker.Key(), candidate.chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.Metadata, input.Prepared.chunkValidators, candidate.terminal.envelope.Attempt, input.Debug) - attemptWarnings := append(cloneWarnings(output.Warnings), report.Warnings()...) payload := map[string]any{ "plan": debugChunkPlanEnvelope(candidate.plan), "materialized_chunks": debugSourceChunkEnvelopes(candidate.chunks), - "warnings": debugWarningEnvelopes(attemptWarnings), "rejection": debugRejectedOutputPtr(chunkRejection(report, candidate.terminal.envelope.Attempt, chunker.Key())), + "rejection": debugRejectedOutputPtr(chunkRejection(report, candidate.terminal.envelope.Attempt, chunker.Key())), } if validationErr != nil { return report, candidate.terminal.record(payload, validationErr) @@ -208,12 +204,11 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S result.rejection.Stage = string(StageChunk) result.rejection.ModuleKey = chunker.Key() } - result.warnings = cloneWarnings(terminal.Warnings) result.validation = &terminalSummary if result.rejection != nil { result.rejection.Validation = cloneValidationSummaryPtr(result.validation) } - result.setValidation(terminal.Validation.Warnings(), result.rejection, nil) + result.setValidation(result.rejection, nil) return result, nil } candidate, ok := terminal.Value.(generatedChunkPlanCandidate) @@ -234,9 +229,8 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S result.accepted = true result.plan = &candidate.plan result.chunks = candidate.chunks - result.warnings = cloneWarnings(terminal.Warnings) result.validation = &terminalSummary - result.setValidation(terminal.Validation.Warnings(), nil, nil) + result.setValidation(nil, nil) if terminal.ValidationIncomplete { result.summary.ValidationStatus = "incomplete" } @@ -246,7 +240,6 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S if cloneErr != nil { return result, fmt.Errorf("clone chunk plan record for publication: %w", cloneErr) } - record.Warnings = cloneWarnings(candidate.producerWarnings) record.Diagnostics = contracts.CloneProducerDiagnostics(candidate.producerDiagnostics) if err := input.ChunkPlans.Save(record); err != nil { return result, fmt.Errorf("save chunk plan: %w", err) @@ -303,14 +296,12 @@ func (result *chunkPlanExecution) setCandidate(record ChunkPlanRecord, action st return nil } -func (result *chunkPlanExecution) setValidation(warnings []contracts.Warning, rejection *contracts.RejectedOutput, err error) { +func (result *chunkPlanExecution) setValidation(rejection *contracts.RejectedOutput, err error) { switch { case err != nil: result.summary.ValidationStatus = "error" case rejection != nil: result.summary.ValidationStatus = "rejected" - case len(warnings) > 0: - result.summary.ValidationStatus = "approved_with_warnings" default: result.summary.ValidationStatus = "approved" } @@ -324,7 +315,6 @@ func cloneChunkPlanRecord(record ChunkPlanRecord) (ChunkPlanRecord, error) { return ChunkPlanRecord{}, fmt.Errorf("clone chunk plan producer metadata: %w", err) } record.Producer.Metadata = metadata - record.Warnings = cloneWarnings(record.Warnings) record.Diagnostics = contracts.CloneProducerDiagnostics(record.Diagnostics) return record, nil } diff --git a/internal/framework/pipeline/runner_chunk_plan_test.go b/internal/framework/pipeline/runner_chunk_plan_test.go index c3fcaa38..7e18f698 100644 --- a/internal/framework/pipeline/runner_chunk_plan_test.go +++ b/internal/framework/pipeline/runner_chunk_plan_test.go @@ -118,9 +118,9 @@ func (*retryingChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil func (c *retryingChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) { c.calls++ if c.calls == 1 { - return contracts.ChunkPlanResult{Warnings: []contracts.Warning{{Scope: "discarded", ReasonCode: "retry", Message: "discarded warning"}}}, errors.New("retry generation") + return contracts.ChunkPlanResult{Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("discarded", "discarded diagnostic")}}, errors.New("retry generation") } - return contracts.ChunkPlanResult{Plan: source.CloneChunkPlan(c.plan), Warnings: []contracts.Warning{{Scope: "accepted", ReasonCode: "observed", Message: "accepted warning"}}}, nil + return contracts.ChunkPlanResult{Plan: source.CloneChunkPlan(c.plan), Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted diagnostic")}}, nil } type dependencyLoader struct { @@ -399,7 +399,7 @@ func TestRunnerRegeneratesValidationIncompleteChunkPlanHit(t *testing.T) { }}, }} record := chunkPlanRecord(t, prepared, plan) - record.Warnings = []contracts.Warning{{Scope: "stored", ReasonCode: "old", Message: "discarded stored warning"}} + record.Diagnostics = []contracts.ProducerDiagnostic{producerDiagnostic("stored", "discarded stored diagnostic")} store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}} debug := newCapturedDebugRecorder() @@ -413,8 +413,8 @@ func TestRunnerRegeneratesValidationIncompleteChunkPlanHit(t *testing.T) { if output.ChunkPlan == nil || output.ChunkPlan.Action != "generated" || output.ChunkPlan.LookupStatus != "invalid" || output.ChunkPlan.ValidationStatus != "approved" { t.Fatalf("chunk plan summary = %#v", output.ChunkPlan) } - if len(output.Warnings) != 0 { - t.Fatalf("warnings = %#v, want discarded cache warnings omitted", output.Warnings) + if len(output.Diagnostics.Groups) != 0 { + t.Fatalf("diagnostics = %#v, want discarded cache diagnostics omitted", output.Diagnostics) } assertAttemptEnvelopeSequence(t, debug, "chunk", 1) } @@ -427,7 +427,7 @@ func TestRunnerKeepsStoredPlanWhenCacheAndGeneratedValidationAreIncomplete(t *te validator := &countingChunkValidator{err: errors.New("validator unavailable")} prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}} record := chunkPlanRecord(t, prepared, plan) - record.Warnings = []contracts.Warning{{Scope: "stored", ReasonCode: "old", Message: "discarded stored warning"}} + record.Diagnostics = []contracts.ProducerDiagnostic{producerDiagnostic("stored", "discarded stored diagnostic")} store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}} output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store}) @@ -440,8 +440,8 @@ func TestRunnerKeepsStoredPlanWhenCacheAndGeneratedValidationAreIncomplete(t *te if output.ChunkPlan == nil || output.ChunkPlan.Action != "generated" || output.ChunkPlan.LookupStatus != "invalid" || output.ChunkPlan.ValidationStatus != "incomplete" { t.Fatalf("chunk plan summary = %#v", output.ChunkPlan) } - if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != "validator_execution_incomplete" { - t.Fatalf("warnings = %#v, want only generated incomplete warning", output.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != "validator_execution_incomplete" { + t.Fatalf("diagnostics = %#v, want only generated incomplete diagnostic", output.Diagnostics) } if !reflect.DeepEqual(store.record, record) { t.Fatal("discarded incomplete candidates mutated the stored record") @@ -611,8 +611,8 @@ func TestRunnerRetriesBeforePublishingAcceptedPlan(t *testing.T) { if chunker.calls != 2 || store.saves != 1 { t.Fatalf("module calls = %d saves = %d, want 2 and 1", chunker.calls, store.saves) } - if len(output.Warnings) != 1 || output.Warnings[0].Scope != "accepted" || len(store.saved.Warnings) != 1 || store.saved.Warnings[0].Scope != "accepted" { - t.Fatalf("output warnings = %#v stored warnings = %#v", output.Warnings, store.saved.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].Samples[0].Scope != "accepted" || len(store.saved.Diagnostics) != 1 || store.saved.Diagnostics[0].Samples[0].Scope != "accepted" { + t.Fatalf("output diagnostics = %#v stored diagnostics = %#v", output.Diagnostics, store.saved.Diagnostics) } } @@ -624,7 +624,7 @@ func TestRunnerAutoHitValidatesOnceWithoutRegenerationOrMutation(t *testing.T) { wantError string wantReject bool }{ - {name: "warning", result: contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: "current", ReasonCode: "observed", Message: "current warning"}}}}, + {name: "diagnostic", result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("current", "current diagnostic")}}}, {name: "rejection", result: contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected hit", CorrectionGuidance: "return an acceptable chunk plan"}, wantReject: true}, {name: "error", validatorErr: errors.New("validator failed"), wantError: "validator failed"}, } @@ -637,7 +637,7 @@ func TestRunnerAutoHitValidatesOnceWithoutRegenerationOrMutation(t *testing.T) { validator := &countingChunkValidator{result: tc.result, err: tc.validatorErr} prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}} record := chunkPlanRecord(t, prepared, plan) - record.Warnings = []contracts.Warning{{Scope: "stored", ReasonCode: "observed", Message: "stored warning"}} + record.Diagnostics = []contracts.ProducerDiagnostic{producerDiagnostic("stored", "stored diagnostic")} store := &recordingChunkPlanStore{record: record, decision: ChunkPlanDecision{Status: ChunkPlanHit}} debug := newCapturedDebugRecorder() output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheAuto, ChunkPlans: store, Debug: debug}) @@ -673,8 +673,8 @@ func TestRunnerAutoHitValidatesOnceWithoutRegenerationOrMutation(t *testing.T) { if tc.wantReject && (len(output.Rejected) != 1 || output.Rejected[0].ReasonCode != "rejected") { t.Fatalf("rejected = %#v", output.Rejected) } - if tc.wantError == "" && !tc.wantReject && len(output.Warnings) != 1+len(tc.result.Warnings) { - t.Fatalf("warnings = %#v, want stored warning once plus current warnings", output.Warnings) + if tc.wantError == "" && !tc.wantReject && len(output.Diagnostics.Groups) != 1+len(tc.result.Diagnostics) { + t.Fatalf("diagnostics = %#v, want stored diagnostic once plus current diagnostics", output.Diagnostics) } }) } @@ -724,7 +724,7 @@ func TestRunnerPublishesOnlyAcceptedGeneratedPlans(t *testing.T) { } } -func TestRunnerStoresProducerProvenanceAndProducerWarnings(t *testing.T) { +func TestRunnerStoresProducerProvenanceAndDiagnostics(t *testing.T) { prepared, plan := preparedTerminalDebugPipeline(t) prepared.resolved.Chunk.LLMProfile = "chunk-profile" prepared.resolved.ChunkExecutionClass = contracts.ExecutionClassLLMBacked @@ -734,8 +734,8 @@ func TestRunnerStoresProducerProvenanceAndProducerWarnings(t *testing.T) { "guide": {Items: []contracts.ReferenceItem{{SlotName: "guide", Digest: "sha256:guide", Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///guide.txt"}, Content: []byte("sensitive")}}}, }}, } - prepared.chunker = manifestChunker{terminalChunker: terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, warnings: []contracts.Warning{{Scope: "producer", ReasonCode: "observed", Message: "producer warning"}}}, metadata: map[string]any{"prompt_id": "chunk/prompt"}} - validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: "validator", ReasonCode: "observed", Message: "validator warning"}}}} + prepared.chunker = manifestChunker{terminalChunker: terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("producer", "producer diagnostic")}}, metadata: map[string]any{"prompt_id": "chunk/prompt"}} + validator := &countingChunkValidator{result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("validator", "validator diagnostic")}}} prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding(validator.Name()), Target: ValidatorTargetChunk}, chunk: validator}} store := &recordingChunkPlanStore{} if _, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), ChunkCacheMode: ChunkCacheRefresh, ChunkPlans: store}); err != nil { @@ -748,8 +748,8 @@ func TestRunnerStoresProducerProvenanceAndProducerWarnings(t *testing.T) { if len(producer.References) != 1 || producer.References[0].Digest != "sha256:guide" { t.Fatalf("producer references = %#v", producer.References) } - if len(store.saved.Warnings) != 1 || store.saved.Warnings[0].Scope != "producer" { - t.Fatalf("stored warnings = %#v, want only producer warning", store.saved.Warnings) + if len(store.saved.Diagnostics) != 1 || store.saved.Diagnostics[0].Samples[0].Scope != "producer" { + t.Fatalf("stored diagnostics = %#v, want only producer diagnostic", store.saved.Diagnostics) } } diff --git a/internal/framework/pipeline/runner_concurrency_test.go b/internal/framework/pipeline/runner_concurrency_test.go index 95d68983..f5f76fa2 100644 --- a/internal/framework/pipeline/runner_concurrency_test.go +++ b/internal/framework/pipeline/runner_concurrency_test.go @@ -201,9 +201,9 @@ func TestRunnerDiscardsOutputReturnedAfterCancellation(t *testing.T) { if len(output.OutputFiles) != 0 { t.Fatalf("output files = %#v, want none", output.OutputFiles) } - for _, warning := range output.Warnings { - if warning.ReasonCode == "returned-after-cancel" { - t.Fatalf("output warnings include encoder warning after cancellation") + for _, group := range output.Diagnostics.Groups { + if group.ReasonCode == "returned-after-cancel" { + t.Fatalf("output diagnostics include encoder diagnostic after cancellation") } } } @@ -367,7 +367,7 @@ func TestRunnerBoundsExtractJobsAndStabilizesReverseCompletion(t *testing.T) { return erasedTypedResult{}, ctx.Err() } scope := fmt.Sprintf("lane-%d/chunk-%d", lane, request.Chunk.Index) - return erasedTypedResult{Value: typedValueForLane(lane, request.Chunk.Index), Warnings: []contracts.Warning{{Scope: scope, ReasonCode: "observed", Message: "ordered"}}, Diagnostics: []contracts.ProducerDiagnostic{{Disposition: contracts.DiagnosticDispositionObservation, Category: contracts.DiagnosticCategoryNormalization, ReasonCode: "observed", OccurrenceCount: 1, Samples: []contracts.DiagnosticSample{{Scope: scope, Message: "ordered"}}}}}, nil + return erasedTypedResult{Value: typedValueForLane(lane, request.Chunk.Index), Diagnostics: []contracts.ProducerDiagnostic{{Disposition: contracts.DiagnosticDispositionObservation, Category: contracts.DiagnosticCategoryNormalization, ReasonCode: "observed", OccurrenceCount: 1, Samples: []contracts.DiagnosticSample{{Scope: scope, Message: "ordered"}}}}}, nil }) } @@ -396,14 +396,6 @@ func TestRunnerBoundsExtractJobsAndStabilizesReverseCompletion(t *testing.T) { if got := maximum.Load(); got != 2 { t.Fatalf("maximum concurrent extract jobs = %d, want 2", got) } - wantScopes := []string{"lane-0/chunk-0", "lane-0/chunk-1", "lane-0/chunk-2", "lane-1/chunk-0", "lane-1/chunk-1", "lane-1/chunk-2"} - gotScopes := make([]string, len(result.output.Warnings)) - for i := range result.output.Warnings { - gotScopes[i] = result.output.Warnings[i].Scope - } - if !reflect.DeepEqual(gotScopes, wantScopes) { - t.Fatalf("warning order = %#v, want %#v", gotScopes, wantScopes) - } if len(result.output.Diagnostics.Groups) != 2 { t.Fatalf("diagnostic groups = %#v, want one deterministic group per lane", result.output.Diagnostics.Groups) } diff --git a/internal/framework/pipeline/runner_concurrent.go b/internal/framework/pipeline/runner_concurrent.go index 58b4aa40..6e876f7a 100644 --- a/internal/framework/pipeline/runner_concurrent.go +++ b/internal/framework/pipeline/runner_concurrent.go @@ -24,7 +24,6 @@ type laneExtractState struct { reuseEligible bool values []erasedExtractArtifact serialized []CheckpointArtifact - warnings []contracts.Warning diagnostics []contracts.DiagnosticGroup rejected []contracts.RejectedOutput incomplete []int @@ -39,7 +38,6 @@ type laneExtractState struct { type finalizedExtractResults struct { accepted []erasedExtractArtifact serialized []CheckpointArtifact - warnings []contracts.Warning diagnostics []contracts.DiagnosticGroup rejected []contracts.RejectedOutput incomplete []int @@ -55,8 +53,8 @@ func loadExtract(loader CheckpointLoader, stepID, laneID, moduleKey string, deps return loader.Extract(laneID, moduleKey, deps) } -func recordExtract(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { - return checkpointExtractSucceeded(recorder, stepID, laneID, moduleKey, deps, outputs, rejected, warnings) +func recordExtract(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput) error { + return checkpointExtractSucceeded(recorder, stepID, laneID, moduleKey, deps, outputs, rejected) } type extractJob struct { @@ -69,7 +67,6 @@ type extractJobResult struct { chunkIndex int value erasedExtractArtifact serialized CheckpointArtifact - warnings []contracts.Warning diagnostics []contracts.DiagnosticGroup rejected *contracts.RejectedOutput validationIncomplete bool @@ -356,7 +353,6 @@ func hydrateRequiredLane(input RunInput, loader CheckpointLoader, doc *source.So return state, err } hydrated := resolution.artifacts[0] - local.Warnings = append(local.Warnings, cloneWarnings(checkpoint.Warnings)...) diagnostics, diagnosticErr := promoteCheckpointDiagnostics(hydrated.Diagnostics, contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageNormalize, StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module}, nil) if diagnosticErr != nil { return state, fmt.Errorf("promote reused accepted diagnostics: %w", diagnosticErr) @@ -433,7 +429,7 @@ func prepareLaneExtract(input RunInput, loader CheckpointLoader, doc *source.Sou } state.diagnostics = append(state.diagnostics, diagnostics...) } - state.warnings, state.rejected = cloneWarnings(cp.Warnings), cloneRejectedOutputs(cp.Rejected) + state.rejected = cloneRejectedOutputs(cp.Rejected) } return state, nil } @@ -464,15 +460,14 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source. return producerAttemptOutput{}, terminal.record(nil, attemptErr) } artifact := erasedExtractArtifact{LaneID: lane.ID, ExtractorKey: lane.Extract.Module, SourceID: doc.ID, ChunkID: chunk.ID, ChunkIndex: chunk.Index, ChunkRef: chunk.Ref, Value: extracted.Value} - attemptWarnings := cloneWarnings(extracted.Warnings) serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, artifact.LaneID, artifact.ExtractorKey, artifact.SourceID, artifact.Value) if encodeErr != nil { attemptErr := fmt.Errorf("serialize extract candidate for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr) - payload := map[string]any{"warnings": debugWarningEnvelopes(attemptWarnings)} + payload := map[string]any{} return producerAttemptOutput{}, terminal.record(payload, attemptErr) } serializedCandidate.ChunkID, serializedCandidate.ChunkIndex, serializedCandidate.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef - return producerAttemptOutput{Value: extractAttemptValue{artifact: artifact, serialized: serializedCandidate, terminal: &terminal}, Candidate: extracted.ModelCandidate, Warnings: attemptWarnings, Diagnostics: contracts.CloneProducerDiagnostics(extracted.Diagnostics)}, nil + return producerAttemptOutput{Value: extractAttemptValue{artifact: artifact, serialized: serializedCandidate, terminal: &terminal}, Candidate: extracted.ModelCandidate, Diagnostics: contracts.CloneProducerDiagnostics(extracted.Diagnostics)}, nil }, func(validationCtx context.Context, output producerAttemptOutput) (validationReport, error) { candidate, ok := output.Value.(extractAttemptValue) if !ok { @@ -481,7 +476,6 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source. report, validationErr := r.validateTypedReport(validationCtx, typed.codec, typedValidationTarget{stage: StageExtract, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Extract.Module, source: doc, sourceID: doc.ID, sourceInput: chunkInputMaterial(sourceInput, chunk), sessionID: sessionID, references: operationReferenceSet(input, lane.ExtractReferences), metadata: input.Metadata, chunk: &chunk, ref: chunk.Ref, value: candidate.artifact.Value, candidate: &candidate.serialized}, state.prepared.extractValidators, candidate.terminal.envelope.Attempt, input.Debug) payload := map[string]any{ "output": debugCheckpointArtifact(candidate.serialized), - "warnings": debugWarningEnvelopes(append(cloneWarnings(output.Warnings), report.Warnings()...)), "rejection": debugRejectedOutputPtr(typedRejection(report, typedValidationTarget{stage: StageExtract, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Extract.Module, chunk: &chunk}, candidate.terminal.envelope.Attempt)), } if validationErr != nil { @@ -510,7 +504,6 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source. result.validationSummary = &summary result.rejected.Validation = cloneValidationSummaryPtr(result.validationSummary) } - result.warnings = cloneWarnings(terminalResult.Warnings) return result } if err == nil { @@ -520,7 +513,7 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source. return result } stored, encodeErr := checkpointArtifact(typed.codec, candidate.artifact.LaneID, candidate.artifact.ExtractorKey, candidate.artifact.SourceID, candidate.artifact.Value) - payload := map[string]any{"output": debugCheckpointArtifact(candidate.serialized), "warnings": debugWarningEnvelopes(terminalResult.Warnings), "rejection": debugRejectedOutputPtr(nil)} + payload := map[string]any{"output": debugCheckpointArtifact(candidate.serialized), "rejection": debugRejectedOutputPtr(nil)} if encodeErr != nil { attemptErr := fmt.Errorf("serialize accepted extract output for lane %q chunk %q: %w", lane.ID, chunk.ID, encodeErr) result.err = candidate.terminal.record(payload, attemptErr) @@ -533,7 +526,6 @@ func (r *Runner) runExtractJob(ctx context.Context, input RunInput, doc *source. return result } result.value, result.serialized = candidate.artifact, stored - result.warnings = cloneWarnings(terminalResult.Warnings) result.validationIncomplete = terminalResult.ValidationIncomplete result.validationSummary = &summary } @@ -554,13 +546,11 @@ func finalizeLaneExtract(checkpoints CheckpointRecorder, stepID string, state *l } if result.rejected != nil { state.rejected = append(state.rejected, *result.rejected) - state.warnings = append(state.warnings, result.warnings...) state.diagnostics = append(state.diagnostics, result.diagnostics...) continue } state.values = append(state.values, result.value) state.serialized = append(state.serialized, result.serialized) - state.warnings = append(state.warnings, result.warnings...) state.diagnostics = append(state.diagnostics, result.diagnostics...) if result.validationIncomplete { state.incomplete = append(state.incomplete, result.chunkIndex) @@ -574,7 +564,7 @@ func finalizeLaneExtract(checkpoints CheckpointRecorder, stepID string, state *l state.reuseEligible = false } if !state.decision.Reused && state.reuseEligible { - if err := recordExtract(checkpoints, stepID, lane.ID, lane.Extract.Module, state.deps, state.serialized, state.rejected, state.warnings); err != nil { + if err := recordExtract(checkpoints, stepID, lane.ID, lane.Extract.Module, state.deps, state.serialized, state.rejected); err != nil { return fmt.Errorf("write extract checkpoint for lane %q: %w", lane.ID, err) } } @@ -587,7 +577,6 @@ func (r *Runner) continueLane(ctx context.Context, input RunInput, checkpoints C results := finalizedExtractResults{ accepted: state.values, serialized: state.serialized, - warnings: state.warnings, diagnostics: state.diagnostics, rejected: state.rejected, incomplete: state.incomplete, @@ -595,14 +584,13 @@ func (r *Runner) continueLane(ctx context.Context, input RunInput, checkpoints C decision: state.decision, reuseEligible: state.reuseEligible, } - local.Warnings = append(local.Warnings, cloneWarnings(results.warnings)...) local.diagnosticGroups = append(local.diagnosticGroups, contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: results.diagnostics}).Groups...) local.Rejected = append(local.Rejected, cloneRejectedOutputs(results.rejected)...) local.ValidationSummaries = append(local.ValidationSummaries, cloneValidationSummaries(results.validationSummaries)...) if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "decision": results.decision, "source": debugSourceDocumentEnvelope(doc), "chunks": debugSourceChunkEnvelopes(chunks), "options": redactSensitiveMap(lane.Extract.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil { return local, &laneRunError{stage: StageExtract, err: err} } - if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "warnings": debugWarningEnvelopes(results.warnings), "validation_incomplete_chunks": append([]int(nil), results.incomplete...)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("extract", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": results.decision.Reused, "outputs": debugCheckpointArtifacts(results.serialized), "rejected": debugRejectedOutputEnvelopes(results.rejected), "validation_incomplete_chunks": append([]int(nil), results.incomplete...)}}); err != nil { return local, &laneRunError{stage: StageExtract, err: err} } if len(results.accepted) == 0 { @@ -667,7 +655,6 @@ func mergeLaneOutput(dst *RunOutput, src RunOutput) error { } dst.NormalizeOutputs = append(dst.NormalizeOutputs, cloneSerializedOutputs(src.NormalizeOutputs)...) dst.Rejected = append(dst.Rejected, cloneRejectedOutputs(src.Rejected)...) - dst.Warnings = append(dst.Warnings, cloneWarnings(src.Warnings)...) appendDiagnosticGroups(dst, src.diagnosticGroups) dst.CheckpointEvents = append(dst.CheckpointEvents, src.CheckpointEvents...) dst.ValidationSummaries = append(dst.ValidationSummaries, cloneValidationSummaries(src.ValidationSummaries)...) diff --git a/internal/framework/pipeline/runner_extract_correction_test.go b/internal/framework/pipeline/runner_extract_correction_test.go index 1778005d..483db420 100644 --- a/internal/framework/pipeline/runner_extract_correction_test.go +++ b/internal/framework/pipeline/runner_extract_correction_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "reflect" "strings" "sync" "testing" @@ -85,15 +84,6 @@ func TestRunnerCorrectsExtractCandidatesIndependentlyPerChunk(t *testing.T) { } } -func containsWarnings(have, want []contracts.Warning) bool { - for index := 0; index+len(want) <= len(have); index++ { - if reflect.DeepEqual(have[index:index+len(want)], want) { - return true - } - } - return false -} - func containsValidationSummary(summaries []artifacts.ValidationSummary, status string) bool { for _, summary := range summaries { if summary.Status == status { @@ -126,8 +116,8 @@ func TestRunnerContinuesValidationIncompleteExtractWithoutCheckpoint(t *testing. if output.Manifest.ValidationStatus != "incomplete" || !containsValidationSummary(output.Manifest.ValidationSummaries, "incomplete") { t.Fatalf("manifest validation = %#v, want incomplete extract provenance", output.Manifest) } - if got, want := output.Warnings, []contracts.Warning{{Scope: "typed/check", ReasonCode: "validator_execution_incomplete", Message: "Validator execution did not complete within its configured budget."}}; !containsWarnings(got, want) { - t.Fatalf("warnings = %#v, want %#v", got, want) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != "validator_execution_incomplete" { + t.Fatalf("diagnostics = %#v, want validation-incomplete group", output.Diagnostics) } if len(recorder.checkpoint.Outputs) != 0 || len(recorder.checkpoint.Rejected) != 0 { t.Fatalf("extract checkpoint = %#v, want no persisted incomplete output", recorder.checkpoint) diff --git a/internal/framework/pipeline/runner_extract_handoff_test.go b/internal/framework/pipeline/runner_extract_handoff_test.go index fb923cbc..ae80b4af 100644 --- a/internal/framework/pipeline/runner_extract_handoff_test.go +++ b/internal/framework/pipeline/runner_extract_handoff_test.go @@ -35,11 +35,10 @@ func (v *mutatingMetadataValidator) Validate(_ context.Context, request contract return contracts.ValidationResult{Approved: true}, nil } -func (r *extractCaptureRecorder) ExtractSucceeded(_ string, _ string, _ []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { +func (r *extractCaptureRecorder) ExtractSucceeded(_ string, _ string, _ []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput) error { r.checkpoint = ExtractCheckpoint{ Outputs: cloneCheckpointArtifacts(outputs), Rejected: cloneRejectedOutputs(rejected), - Warnings: cloneWarnings(warnings), } return nil } @@ -56,7 +55,6 @@ func (l *extractResultLoader) Extract(string, string, []CheckpointFingerprint) ( return ExtractCheckpoint{ Outputs: cloneCheckpointArtifacts(l.checkpoint.Outputs), Rejected: cloneRejectedOutputs(l.checkpoint.Rejected), - Warnings: cloneWarnings(l.checkpoint.Warnings), }, l.decision } @@ -156,8 +154,7 @@ func TestRunnerContinuesFromFreshAndReusedExtractResults(t *testing.T) { installExtractOperation(prepared, 0, func(_ context.Context, request contracts.TypedExtractionRequest) (erasedTypedResult, error) { extractCalls++ return erasedTypedResult{ - Value: typedValueForLane(0, request.Chunk.Index), - Warnings: []contracts.Warning{{Scope: "extract", ReasonCode: "observed", Message: "accepted extract"}}, + Value: typedValueForLane(0, request.Chunk.Index), Diagnostics: []contracts.ProducerDiagnostic{{ Disposition: contracts.DiagnosticDispositionObservation, Category: contracts.DiagnosticCategoryNormalization, @@ -213,15 +210,12 @@ func TestRunnerContinuesFromFreshAndReusedExtractResults(t *testing.T) { if !reflect.DeepEqual(reused.NormalizeOutputs, fresh.NormalizeOutputs) { t.Fatalf("reused normalize outputs = %#v, want fresh outputs %#v", reused.NormalizeOutputs, fresh.NormalizeOutputs) } - if !reflect.DeepEqual(reused.Warnings, fresh.Warnings) { - t.Fatalf("reused warnings = %#v, want fresh warnings %#v", reused.Warnings, fresh.Warnings) - } if !reflect.DeepEqual(reused.Diagnostics, fresh.Diagnostics) { t.Fatalf("reused diagnostics = %#v, want fresh diagnostics %#v", reused.Diagnostics, fresh.Diagnostics) } } -func TestRunnerPromotesOnlyAcceptedExtractRetryWarnings(t *testing.T) { +func TestRunnerPromotesOnlyAcceptedExtractRetryDiagnostics(t *testing.T) { prepared := preparedAttemptDebugPipeline(t) prepared.Steps[0].lanes[0].resolved.Extract.Retries = 1 attempts := 0 @@ -233,7 +227,7 @@ func TestRunnerPromotesOnlyAcceptedExtractRetryWarnings(t *testing.T) { } return erasedTypedResult{ Value: typedValueForLane(0, request.Chunk.Index), - Warnings: []contracts.Warning{{Scope: scope, ReasonCode: "observed", Message: scope}}, + Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(scope, scope)}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%s"]}`, scope)), }, nil }) @@ -250,8 +244,8 @@ func TestRunnerPromotesOnlyAcceptedExtractRetryWarnings(t *testing.T) { if attempts != 2 { t.Fatalf("extract attempts = %d, want 2", attempts) } - if len(output.Warnings) != 1 || output.Warnings[0].Scope != "accepted" { - t.Fatalf("promoted warnings = %#v, want accepted attempt only", output.Warnings) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].Samples[0].Scope != "accepted" { + t.Fatalf("promoted diagnostics = %#v, want accepted attempt only", output.Diagnostics) } assertAttemptEnvelopeSequence(t, debug, "extract/notes/chunk-000001", 1, 2) first := debug.envelope(t, "extract/notes/chunk-000001/attempt-01.json") diff --git a/internal/framework/pipeline/runner_normalize_retry_test.go b/internal/framework/pipeline/runner_normalize_retry_test.go index a88b172a..aa0daf9a 100644 --- a/internal/framework/pipeline/runner_normalize_retry_test.go +++ b/internal/framework/pipeline/runner_normalize_retry_test.go @@ -17,7 +17,7 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) { validator *preparedValidator wantCalls int wantItem string - wantWarnings []string + wantMessages []string wantRejected int wantDebug []string wantCheckpoint int @@ -30,7 +30,7 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) { }, wantCalls: 1, wantItem: "fallback", - wantWarnings: []string{"ordinary", "fallback-warning"}, + wantMessages: []string{"ordinary", "fallback-warning"}, wantDebug: []string{`"another_attempt":false`, `"fallback_accepted":true`}, wantCheckpoint: 1, }, @@ -41,11 +41,11 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) { if attempt == 1 { return retryableNormalizeResult("discarded", "discarded-ordinary", "discarded-fallback") } - return erasedTypedResult{Value: codecNotes{Items: []string{"accepted"}}, Warnings: []contracts.Warning{{Scope: "accepted", ReasonCode: "ordinary", Message: "accepted-warning"}}} + return erasedTypedResult{Value: codecNotes{Items: []string{"accepted"}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted-warning")}} }, wantCalls: 2, wantItem: "accepted", - wantWarnings: []string{"accepted-warning"}, + wantMessages: []string{"accepted-warning"}, wantDebug: []string{`"another_attempt":true`, `"fallback_accepted":false`}, wantCheckpoint: 1, }, @@ -57,7 +57,7 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) { }, wantCalls: 2, wantItem: "fallback-2", - wantWarnings: []string{"ordinary-2", "fallback-warning-2"}, + wantMessages: []string{"ordinary-2", "fallback-warning-2"}, wantDebug: []string{`"another_attempt":false`, `"fallback_accepted":true`}, wantCheckpoint: 1, }, @@ -140,12 +140,14 @@ func TestRunnerHandlesRetryableNormalizeFallbacks(t *testing.T) { if got := firstNote(normalized); got != tc.wantItem { t.Fatalf("normalized item = %q, want %q", got, tc.wantItem) } - gotWarnings := make([]string, len(output.Warnings)) - for index, warning := range output.Warnings { - gotWarnings[index] = warning.Message + var gotMessages []string + for _, group := range output.Diagnostics.Groups { + for _, sample := range group.Samples { + gotMessages = append(gotMessages, sample.Message) + } } - if strings.Join(gotWarnings, "|") != strings.Join(tc.wantWarnings, "|") { - t.Fatalf("durable warnings = %#v, want %#v", gotWarnings, tc.wantWarnings) + if strings.Join(gotMessages, "|") != strings.Join(tc.wantMessages, "|") { + t.Fatalf("durable diagnostics = %#v, want %#v", gotMessages, tc.wantMessages) } }) } @@ -262,12 +264,12 @@ func TestRunnerValidatesNormalizeRetryDiagnostics(t *testing.T) { func retryableNormalizeResult(item, ordinary, fallback string) erasedTypedResult { return erasedTypedResult{ - Value: codecNotes{Items: []string{item}}, - Warnings: []contracts.Warning{{Scope: "attempt", ReasonCode: "ordinary", Message: ordinary}}, + Value: codecNotes{Items: []string{item}}, + Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("attempt", ordinary)}, Retry: &contracts.NormalizeRetry{ - ReasonCode: "retryable_normalization", - Message: "safe fallback is available", - FallbackWarnings: []contracts.Warning{{Scope: "fallback", ReasonCode: "fallback", Message: fallback}}, + ReasonCode: "retryable_normalization", + Message: "safe fallback is available", + FallbackDiagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("fallback", fallback)}, }, } } diff --git a/internal/framework/pipeline/runner_rejection_warnings_test.go b/internal/framework/pipeline/runner_rejection_warnings_test.go index 8aab1e68..47c62d57 100644 --- a/internal/framework/pipeline/runner_rejection_warnings_test.go +++ b/internal/framework/pipeline/runner_rejection_warnings_test.go @@ -11,19 +11,19 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) -type warningChunker struct { +type diagnosticChunker struct { key string plan source.ChunkPlan calls int } -func (c *warningChunker) Key() string { return c.key } -func (*warningChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil } -func (c *warningChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) { +func (c *diagnosticChunker) Key() string { return c.key } +func (*diagnosticChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil } +func (c *diagnosticChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) { c.calls++ return contracts.ChunkPlanResult{ - Plan: source.CloneChunkPlan(c.plan), - Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", c.calls), ReasonCode: "operation", Message: "operation warning"}}, + Plan: source.CloneChunkPlan(c.plan), + Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(fmt.Sprintf("operation-%d", c.calls), "operation diagnostic")}, }, nil } @@ -40,14 +40,14 @@ func (v chunkValidationFunc) Validate(ctx context.Context, request contracts.Chu return v.validate(ctx, request) } -func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) { +func TestRunnerPromotesOnlyTerminalRejectionDiagnostics(t *testing.T) { for _, target := range []ModuleStage{StageChunk, StageExtract, StageMerge, StageNormalize} { t.Run(string(target), func(t *testing.T) { prepared := preparedAttemptDebugPipeline(t) lane := &prepared.Steps[0].lanes[0] attempts := 0 first := func() contracts.ValidationResult { - return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", attempts), ReasonCode: "validator", Message: "validator warning"}}} + return contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(fmt.Sprintf("validator-%d", attempts), "validator diagnostic")}} } reject := func() contracts.ValidationResult { return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected", CorrectionGuidance: "return an acceptable candidate"} @@ -59,7 +59,7 @@ func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) { case StageChunk: prepared.resolved.ChunkValidationPolicy.SemanticRejection = SemanticRejectionRejectOutput chunker := prepared.chunker.(*typedTestChunker) - prepared.chunker = &warningChunker{key: prepared.resolved.Chunk.Module, plan: source.CloneChunkPlan(chunker.plan)} + prepared.chunker = &diagnosticChunker{key: prepared.resolved.Chunk.Module, plan: source.CloneChunkPlan(chunker.plan)} prepared.resolved.Chunk.Retries = 1 prepared.chunkValidators.validators = []preparedValidator{ {resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-approval", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) { @@ -69,34 +69,34 @@ func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) { return reject(), nil }}}, } - chunkerWithWarnings := prepared.chunker.(*warningChunker) + chunkerWithDiagnostics := prepared.chunker.(*diagnosticChunker) first = func() contracts.ValidationResult { - return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", chunkerWithWarnings.calls), ReasonCode: "validator", Message: "validator warning"}}} + return contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(fmt.Sprintf("validator-%d", chunkerWithDiagnostics.calls), "validator diagnostic")}} } case StageExtract: lane.resolved.Extract.Retries = 1 lane.resolved.ExtractValidationPolicy.SemanticRejection = SemanticRejectionRejectOutput installExtractOperation(prepared, 0, func(context.Context, contracts.TypedExtractionRequest) (erasedTypedResult, error) { attempts++ - return erasedTypedResult{Value: codecNotes{Items: []string{"extract"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%d"]}`, attempts))}, nil + return erasedTypedResult{Value: codecNotes{Items: []string{"extract"}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(fmt.Sprintf("operation-%d", attempts), "operation diagnostic")}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%d"]}`, attempts))}, nil }) - lane.extractValidators.validators = rejectionWarningTypedValidators(first, reject) + lane.extractValidators.validators = rejectionDiagnosticTypedValidators(first, reject) case StageMerge: lane.resolved.Merge.Retries = 1 lane.resolved.MergeValidationPolicy.SemanticRejection = SemanticRejectionRejectOutput lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) { attempts++ - return erasedTypedResult{Value: codecNotes{Items: []string{"merge"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%d"]}`, attempts))}, nil + return erasedTypedResult{Value: codecNotes{Items: []string{"merge"}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(fmt.Sprintf("operation-%d", attempts), "operation diagnostic")}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%d"]}`, attempts))}, nil } - lane.mergeValidators.validators = rejectionWarningTypedValidators(first, reject) + lane.mergeValidators.validators = rejectionDiagnosticTypedValidators(first, reject) case StageNormalize: lane.resolved.Normalize.Retries = 1 lane.resolved.NormalizeValidationPolicy.SemanticRejection = SemanticRejectionRejectOutput lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) { attempts++ - return erasedTypedResult{Value: codecNotes{Items: []string{"normalize"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%d"]}`, attempts))}, nil + return erasedTypedResult{Value: codecNotes{Items: []string{"normalize"}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic(fmt.Sprintf("operation-%d", attempts), "operation diagnostic")}, ModelCandidate: attemptCandidate(t, fmt.Sprintf(`{"items":["%d"]}`, attempts))}, nil } - lane.normalizeValidators.validators = rejectionWarningTypedValidators(first, reject) + lane.normalizeValidators.validators = rejectionDiagnosticTypedValidators(first, reject) } output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder, Debug: debug}) @@ -109,29 +109,26 @@ func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) { wantScopes = []string{"operation-1", "validator-1"} wantAttempts = 1 } - if got := rejectionWarningScopes(output.Warnings); !reflect.DeepEqual(got, wantScopes) { - t.Fatalf("published warning scopes = %#v, want %#v", got, wantScopes) + if got := rejectionDiagnosticScopes(output.Diagnostics.Groups); !reflect.DeepEqual(got, wantScopes) { + t.Fatalf("published diagnostic scopes = %#v, want %#v", got, wantScopes) } if len(output.Rejected) != 1 || output.Rejected[0].AttemptCount != wantAttempts { t.Fatalf("rejections = %#v, want terminal rejection after %d attempt(s)", output.Rejected, wantAttempts) } - if target == StageExtract && !reflect.DeepEqual(rejectionWarningScopes(recorder.checkpoint.Warnings), wantScopes) { - t.Fatalf("extract checkpoint warnings = %#v, want %#v", recorder.checkpoint.Warnings, wantScopes) - } attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target) if target == StageChunk { attemptPath = "chunk/attempt-01.json" } else if target == StageExtract { attemptPath = "extract/notes/chunk-000001/attempt-01.json" } - if !strings.Contains(string(debug.json[attemptPath]), "operation-1") || !strings.Contains(string(debug.json[attemptPath]), "validator-1") { - t.Fatalf("first attempt debug = %s, want discarded warnings", debug.json[attemptPath]) + if !strings.Contains(string(debug.json[attemptPath]), "rejection") { + t.Fatalf("first attempt debug = %s, want rejection", debug.json[attemptPath]) } }) } } -func rejectionWarningTypedValidators(first func() contracts.ValidationResult, reject func() contracts.ValidationResult) []preparedValidator { +func rejectionDiagnosticTypedValidators(first func() contracts.ValidationResult, reject func() contracts.ValidationResult) []preparedValidator { return []preparedValidator{ {resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) { return first(), nil @@ -142,10 +139,10 @@ func rejectionWarningTypedValidators(first func() contracts.ValidationResult, re } } -func rejectionWarningScopes(warnings []contracts.Warning) []string { - scopes := make([]string, len(warnings)) - for index := range warnings { - scopes[index] = warnings[index].Scope +func rejectionDiagnosticScopes(groups []contracts.DiagnosticGroup) []string { + scopes := make([]string, len(groups)) + for index := range groups { + scopes[index] = groups[index].Samples[0].Scope } return scopes } diff --git a/internal/framework/pipeline/runner_reuse_lineage_test.go b/internal/framework/pipeline/runner_reuse_lineage_test.go index c071c9ed..e08d7041 100644 --- a/internal/framework/pipeline/runner_reuse_lineage_test.go +++ b/internal/framework/pipeline/runner_reuse_lineage_test.go @@ -61,7 +61,7 @@ func (s *reuseLineageCheckpointSpy) ExtractRunning(laneID, _ string, _ []Checkpo return nil } -func (s *reuseLineageCheckpointSpy) ExtractSucceeded(laneID, _ string, _ []CheckpointFingerprint, _ []CheckpointArtifact, _ []contracts.RejectedOutput, _ []contracts.Warning) error { +func (s *reuseLineageCheckpointSpy) ExtractSucceeded(laneID, _ string, _ []CheckpointFingerprint, _ []CheckpointArtifact, _ []contracts.RejectedOutput) error { s.write("extract", "succeeded", laneID) return nil } @@ -76,7 +76,7 @@ func (s *reuseLineageCheckpointSpy) MergeRunning(laneID, _ string, _ []Checkpoin return nil } -func (s *reuseLineageCheckpointSpy) MergeSucceeded(laneID, _ string, _ []CheckpointFingerprint, _ CheckpointArtifact, _ []contracts.Warning) error { +func (s *reuseLineageCheckpointSpy) MergeSucceeded(laneID, _ string, _ []CheckpointFingerprint, _ CheckpointArtifact) error { s.write("merge", "succeeded", laneID) return nil } @@ -96,7 +96,7 @@ func (s *reuseLineageCheckpointSpy) NormalizeRunning(laneID, _ string, _ []Check return nil } -func (s *reuseLineageCheckpointSpy) NormalizeSucceeded(laneID, _ string, _ []CheckpointFingerprint, _ CheckpointArtifact, _ []contracts.Warning) error { +func (s *reuseLineageCheckpointSpy) NormalizeSucceeded(laneID, _ string, _ []CheckpointFingerprint, _ CheckpointArtifact) error { s.write("normalize", "succeeded", laneID) return nil } diff --git a/internal/framework/pipeline/runner_semantic_correction_test.go b/internal/framework/pipeline/runner_semantic_correction_test.go index 9d5ae186..dec4628d 100644 --- a/internal/framework/pipeline/runner_semantic_correction_test.go +++ b/internal/framework/pipeline/runner_semantic_correction_test.go @@ -120,7 +120,7 @@ func TestRunnerContinuesNormalizeAfterValidatorFailure(t *testing.T) { if output.Manifest.ValidationStatus != "incomplete" || !containsValidationSummary(output.Manifest.ValidationSummaries, "incomplete") { t.Fatalf("manifest validation = %#v, want incomplete normalization provenance", output.Manifest) } - if got, want := output.Warnings, []contracts.Warning{{Scope: "unavailable", ReasonCode: "validator_execution_incomplete", Message: "Validator execution did not complete within its configured budget."}}; !containsWarnings(got, want) { - t.Fatalf("warnings = %#v, want %#v", got, want) + if len(output.Diagnostics.Groups) != 1 || output.Diagnostics.Groups[0].ReasonCode != "validator_execution_incomplete" { + t.Fatalf("diagnostics = %#v, want validation-incomplete group", output.Diagnostics) } } diff --git a/internal/framework/pipeline/runner_terminal_debug_test.go b/internal/framework/pipeline/runner_terminal_debug_test.go index 18300457..ff284739 100644 --- a/internal/framework/pipeline/runner_terminal_debug_test.go +++ b/internal/framework/pipeline/runner_terminal_debug_test.go @@ -16,7 +16,6 @@ import ( type terminalChunker struct { key string plan source.ChunkPlan - warnings []contracts.Warning diagnostics []contracts.ProducerDiagnostic err error calls *int @@ -30,7 +29,7 @@ func (c terminalChunker) Plan(context.Context, contracts.ChunkRequest) (contract if c.calls != nil { (*c.calls)++ } - return contracts.ChunkPlanResult{Plan: source.CloneChunkPlan(c.plan), Warnings: cloneWarnings(c.warnings), Diagnostics: contracts.CloneProducerDiagnostics(c.diagnostics)}, c.err + return contracts.ChunkPlanResult{Plan: source.CloneChunkPlan(c.plan), Diagnostics: contracts.CloneProducerDiagnostics(c.diagnostics)}, c.err } type terminalChunkValidator struct { @@ -168,7 +167,7 @@ func TestRunnerRecordsChunkTerminalOutcomes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { prepared, plan := preparedTerminalDebugPipeline(t) - prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, warnings: []contracts.Warning{{Scope: "chunk", ReasonCode: "observed", Message: "chunk warning"}}, err: tc.moduleError} + prepared.chunker = terminalChunker{key: prepared.resolved.Chunk.Module, plan: plan, diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("chunk", "chunk diagnostic")}, err: tc.moduleError} prepared.chunkValidators.validators = []preparedValidator{{resolved: ResolvedValidator{Binding: Binding("terminal/chunk-validator"), Target: ValidatorTargetChunk}, chunk: tc.validator}} debug := newCapturedDebugRecorder() @@ -337,7 +336,7 @@ func TestRunnerRecordsExtractTerminalOutcomes(t *testing.T) { if tc.moduleError != nil { return erasedTypedResult{}, tc.moduleError } - return erasedTypedResult{Value: codecNotes{Items: []string{value}}, Warnings: []contracts.Warning{{Scope: "extract", ReasonCode: "observed", Message: "extract warning"}}}, nil + return erasedTypedResult{Value: codecNotes{Items: []string{value}}, Diagnostics: []contracts.ProducerDiagnostic{producerDiagnostic("extract", "extract diagnostic")}}, nil }) prepared.Steps[0].lanes[0].extractValidators.validators = []preparedValidator{{ resolved: ResolvedValidator{Binding: Binding("terminal/extract-validator"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, @@ -369,9 +368,6 @@ func TestRunnerRecordsExtractTerminalOutcomes(t *testing.T) { t.Fatalf("extract rejection = envelope %#v, outputs %#v", envelope, output.Rejected) } } - if tc.moduleError == nil && !strings.Contains(string(debug.json[attemptPath]), "extract warning") { - t.Fatalf("attempt envelope = %s, want attempt warning", debug.json[attemptPath]) - } }) } } diff --git a/internal/framework/pipeline/runner_typed.go b/internal/framework/pipeline/runner_typed.go index 4b99d322..24118923 100644 --- a/internal/framework/pipeline/runner_typed.go +++ b/internal/framework/pipeline/runner_typed.go @@ -29,11 +29,11 @@ func loadNormalize(loader CheckpointLoader, stepID, laneID, moduleKey string, de } return loader.Normalize(laneID, moduleKey, deps) } -func recordMerge(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { - return checkpointMergeSucceeded(recorder, stepID, laneID, moduleKey, deps, output, warnings) +func recordMerge(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact) error { + return checkpointMergeSucceeded(recorder, stepID, laneID, moduleKey, deps, output) } -func recordNormalize(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { - return checkpointNormalizeSucceeded(recorder, stepID, laneID, moduleKey, deps, output, warnings) +func recordNormalize(recorder CheckpointRecorder, stepID, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact) error { + return checkpointNormalizeSucceeded(recorder, stepID, laneID, moduleKey, deps, output) } func cloneCheckpointArtifact(output CheckpointArtifact) CheckpointArtifact { output.Artifact = contracts.CloneSerializedArtifact(output.Artifact) @@ -265,12 +265,9 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints } var merged erasedMergeArtifact var serializedMerge CheckpointArtifact - var mergeWarnings []contracts.Warning if mergeDecision.Reused { merged = erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: mergeResolution.values[0]} serializedMerge = mergeResolution.artifacts[0] - mergeWarnings = cloneWarnings(mergeCP.Warnings) - output.Warnings = append(output.Warnings, mergeWarnings...) diagnostics, diagnosticErr := promoteCheckpointDiagnostics(serializedMerge.Diagnostics, contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageMerge, StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module}, nil) if diagnosticErr != nil { return stageResult, fmt.Errorf("promote reused merge diagnostics: %w", diagnosticErr) @@ -296,12 +293,11 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints return producerAttemptOutput{}, terminal.record(nil, fmt.Errorf("merge lane %q with merger %q: %w", lane.ID, lane.Merge.Module, callErr)) } candidate := erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: result.Value} - warnings := cloneWarnings(result.Warnings) serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, candidate.LaneID, candidate.MergerKey, candidate.SourceID, candidate.Value) if encodeErr != nil { - return producerAttemptOutput{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(warnings)}, fmt.Errorf("serialize merge candidate for lane %q: %w", lane.ID, encodeErr)) + return producerAttemptOutput{}, terminal.record(nil, fmt.Errorf("serialize merge candidate for lane %q: %w", lane.ID, encodeErr)) } - return producerAttemptOutput{Value: mergeAttemptValue{artifact: candidate, candidate: serializedCandidate, terminal: &terminal}, Candidate: result.ModelCandidate, Warnings: warnings, Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics)}, nil + return producerAttemptOutput{Value: mergeAttemptValue{artifact: candidate, candidate: serializedCandidate, terminal: &terminal}, Candidate: result.ModelCandidate, Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics)}, nil }, func(validationCtx context.Context, output producerAttemptOutput) (validationReport, error) { candidate, ok := output.Value.(mergeAttemptValue) if !ok { @@ -309,7 +305,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints } target := typedValidationTarget{stage: StageMerge, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Merge.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: mergeReferences, metadata: input.Metadata, value: candidate.artifact.Value, candidate: &candidate.candidate} report, validationErr := r.validateTypedReport(validationCtx, typed.codec, target, prepared.mergeValidators, candidate.terminal.envelope.Attempt, input.Debug) - payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "warnings": debugWarningEnvelopes(append(cloneWarnings(output.Warnings), report.Warnings()...)), "rejection": debugRejectedOutputPtr(typedRejection(report, target, candidate.terminal.envelope.Attempt))} + payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "rejection": debugRejectedOutputPtr(typedRejection(report, target, candidate.terminal.envelope.Attempt))} if validationErr != nil { return report, candidate.terminal.record(payload, validationErr) } @@ -340,7 +336,6 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints rejected.Stage, rejected.StepID, rejected.LaneID, rejected.ModuleKey = string(StageMerge), input.stepID, lane.ID, lane.Merge.Module rejected.Validation = &terminalSummary output.ValidationSummaries = append(output.ValidationSummaries, artifacts.CloneValidationSummary(terminalSummary)) - output.Warnings = append(output.Warnings, terminalResult.Warnings...) appendDiagnosticGroups(output, terminalDiagnostics) output.Rejected = append(output.Rejected, *rejected) if stageResult.reuseEligible { @@ -356,7 +351,7 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints return stageResult, fmt.Errorf("merge attempt terminal has incompatible value") } stored, encodeErr := checkpointArtifact(typed.codec, candidate.artifact.LaneID, candidate.artifact.MergerKey, candidate.artifact.SourceID, candidate.artifact.Value) - payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "warnings": debugWarningEnvelopes(terminalResult.Warnings), "rejection": debugRejectedOutputPtr(nil)} + payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "rejection": debugRejectedOutputPtr(nil)} if encodeErr != nil { attemptErr := fmt.Errorf("serialize accepted merge output for lane %q: %w", lane.ID, encodeErr) if stageResult.reuseEligible { @@ -372,21 +367,19 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints return stageResult, debugErr } merged, serializedMerge = candidate.artifact, stored - mergeWarnings = cloneWarnings(terminalResult.Warnings) output.ValidationSummaries = append(output.ValidationSummaries, terminalSummary) stageResult.validationIncomplete = terminalResult.ValidationIncomplete if stageResult.validationIncomplete { stageResult.reuseEligible = false } - output.Warnings = append(output.Warnings, mergeWarnings...) appendDiagnosticGroups(output, terminalDiagnostics) if stageResult.reuseEligible { - if err := recordMerge(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, serializedMerge, mergeWarnings); err != nil { + if err := recordMerge(checkpoints, input.stepID, lane.ID, lane.Merge.Module, mergeDeps, serializedMerge); err != nil { return stageResult, err } } } - if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("merge", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge)}}); err != nil { return stageResult, err } stageResult.artifact = merged @@ -396,7 +389,6 @@ func (r *Runner) runMergeStage(ctx context.Context, input RunInput, checkpoints type normalizeStageResult struct { serialized CheckpointArtifact - warnings []contracts.Warning accepted bool reuseEligible bool } @@ -422,11 +414,8 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi return stageResult, err } var serializedNormalize CheckpointArtifact - var normalizeWarnings []contracts.Warning if normalizeDecision.Reused { serializedNormalize = normalizeResolution.artifacts[0] - normalizeWarnings = cloneWarnings(normalizeCP.Warnings) - output.Warnings = append(output.Warnings, normalizeWarnings...) diagnostics, diagnosticErr := promoteCheckpointDiagnostics(serializedNormalize.Diagnostics, contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageNormalize, StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module}, nil) if diagnosticErr != nil { return stageResult, fmt.Errorf("promote reused normalize diagnostics: %w", diagnosticErr) @@ -451,28 +440,27 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi if callErr != nil { return producerAttemptOutput{}, terminal.record(nil, fmt.Errorf("normalize lane %q with normalizer %q: %w", lane.ID, lane.Normalize.Module, callErr)) } - warnings := cloneWarnings(result.Warnings) serializedCandidate, encodeErr := serializeCandidateArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, result.Value) if encodeErr != nil { - return producerAttemptOutput{}, terminal.record(map[string]any{"warnings": debugWarningEnvelopes(warnings)}, fmt.Errorf("serialize normalize candidate for lane %q: %w", lane.ID, encodeErr)) + return producerAttemptOutput{}, terminal.record(nil, fmt.Errorf("serialize normalize candidate for lane %q: %w", lane.ID, encodeErr)) } attemptValue := normalizeAttemptValue{value: result.Value, candidate: serializedCandidate, terminal: &terminal} var directive *producerRetryDirective if result.Retry != nil { if err := validateNormalizeRetry(result.Retry); err != nil { - return producerAttemptOutput{}, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(warnings)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err)) + return producerAttemptOutput{}, terminal.record(map[string]any{"output": debugCheckpointArtifact(serializedCandidate)}, fmt.Errorf("normalize lane %q returned invalid retry directive: %w", lane.ID, err)) } anotherAttempt := request.Number <= lane.Normalize.Retries attemptValue.retry = map[string]any{"reason_code": result.Retry.ReasonCode, "message": result.Retry.Message, "another_attempt": anotherAttempt, "fallback_accepted": !anotherAttempt} - directive = &producerRetryDirective{FallbackWarnings: cloneWarnings(result.Retry.FallbackWarnings), FallbackDiagnostics: contracts.CloneProducerDiagnostics(result.Retry.FallbackDiagnostics)} + directive = &producerRetryDirective{FallbackDiagnostics: contracts.CloneProducerDiagnostics(result.Retry.FallbackDiagnostics)} if anotherAttempt { - payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "warnings": debugWarningEnvelopes(warnings), "retry": attemptValue.retry} + payload := map[string]any{"output": debugCheckpointArtifact(serializedCandidate), "retry": attemptValue.retry} if debugErr := terminal.record(payload, nil); debugErr != nil { return producerAttemptOutput{}, debugErr } } } - return producerAttemptOutput{Value: attemptValue, Candidate: result.ModelCandidate, Warnings: warnings, Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), Retry: directive}, nil + return producerAttemptOutput{Value: attemptValue, Candidate: result.ModelCandidate, Diagnostics: contracts.CloneProducerDiagnostics(result.Diagnostics), Retry: directive}, nil }, func(validationCtx context.Context, output producerAttemptOutput) (validationReport, error) { candidate, ok := output.Value.(normalizeAttemptValue) if !ok { @@ -480,7 +468,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi } target := typedValidationTarget{stage: StageNormalize, stepID: input.stepID, laneID: lane.ID, moduleKey: lane.Normalize.Module, source: doc, sourceID: doc.ID, sourceInput: sourceInput.Clone(), sessionID: sessionID, references: normalizeReferences, metadata: input.Metadata, value: candidate.value, candidate: &candidate.candidate} report, validationErr := r.validateTypedReport(validationCtx, typed.codec, target, prepared.normalizeValidators, candidate.terminal.envelope.Attempt, input.Debug) - payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "warnings": debugWarningEnvelopes(append(cloneWarnings(output.Warnings), report.Warnings()...)), "rejection": debugRejectedOutputPtr(typedRejection(report, target, candidate.terminal.envelope.Attempt))} + payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "rejection": debugRejectedOutputPtr(typedRejection(report, target, candidate.terminal.envelope.Attempt))} if candidate.retry != nil { payload["retry"] = candidate.retry } @@ -514,7 +502,6 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi rejected.Stage, rejected.StepID, rejected.LaneID, rejected.ModuleKey = string(StageNormalize), input.stepID, lane.ID, lane.Normalize.Module rejected.Validation = &terminalSummary output.ValidationSummaries = append(output.ValidationSummaries, artifacts.CloneValidationSummary(terminalSummary)) - output.Warnings = append(output.Warnings, terminalResult.Warnings...) appendDiagnosticGroups(output, terminalDiagnostics) output.Rejected = append(output.Rejected, *rejected) if stageResult.reuseEligible { @@ -529,7 +516,7 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi return stageResult, fmt.Errorf("normalize attempt terminal has incompatible value") } stored, encodeErr := checkpointArtifact(typed.codec, lane.ID, lane.Normalize.Module, doc.ID, candidate.value) - payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "warnings": debugWarningEnvelopes(terminalResult.Warnings), "rejection": debugRejectedOutputPtr(nil)} + payload := map[string]any{"output": debugCheckpointArtifact(candidate.candidate), "rejection": debugRejectedOutputPtr(nil)} if candidate.retry != nil { payload["retry"] = candidate.retry } @@ -548,24 +535,21 @@ func (r *Runner) runNormalizeStage(ctx context.Context, input RunInput, checkpoi return stageResult, debugErr } serializedNormalize = stored - normalizeWarnings = cloneWarnings(terminalResult.Warnings) output.ValidationSummaries = append(output.ValidationSummaries, terminalSummary) - output.Warnings = append(output.Warnings, normalizeWarnings...) appendDiagnosticGroups(output, terminalDiagnostics) if terminalResult.ValidationIncomplete { stageResult.reuseEligible = false } if stageResult.reuseEligible { - if err := recordNormalize(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, serializedNormalize, normalizeWarnings); err != nil { + if err := recordNormalize(checkpoints, input.stepID, lane.ID, lane.Normalize.Module, normalizeDeps, serializedNormalize); err != nil { return stageResult, err } } } - if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("normalize", fileio.EncodePathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), StepID: input.stepID, LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize)}}); err != nil { return stageResult, err } stageResult.serialized = serializedNormalize - stageResult.warnings = normalizeWarnings stageResult.accepted = true return stageResult, nil } diff --git a/internal/framework/pipeline/synchronized_collaborators.go b/internal/framework/pipeline/synchronized_collaborators.go index 23121b5d..6b3fc348 100644 --- a/internal/framework/pipeline/synchronized_collaborators.go +++ b/internal/framework/pipeline/synchronized_collaborators.go @@ -134,8 +134,8 @@ func (r *lockedCheckpointRecorder) SourceFailed(key string, err error) error { func (r *lockedCheckpointRecorder) ExtractRunning(lane, key string, deps []CheckpointFingerprint) error { return r.call(func() error { return r.inner.ExtractRunning(lane, key, deps) }) } -func (r *lockedCheckpointRecorder) ExtractSucceeded(lane, key string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { - return r.call(func() error { return r.inner.ExtractSucceeded(lane, key, deps, outputs, rejected, warnings) }) +func (r *lockedCheckpointRecorder) ExtractSucceeded(lane, key string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput) error { + return r.call(func() error { return r.inner.ExtractSucceeded(lane, key, deps, outputs, rejected) }) } func (r *lockedCheckpointRecorder) ExtractFailed(lane, key string, deps []CheckpointFingerprint, err error) error { return r.call(func() error { return r.inner.ExtractFailed(lane, key, deps, err) }) @@ -143,8 +143,8 @@ func (r *lockedCheckpointRecorder) ExtractFailed(lane, key string, deps []Checkp func (r *lockedCheckpointRecorder) MergeRunning(lane, key string, deps []CheckpointFingerprint) error { return r.call(func() error { return r.inner.MergeRunning(lane, key, deps) }) } -func (r *lockedCheckpointRecorder) MergeSucceeded(lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { - return r.call(func() error { return r.inner.MergeSucceeded(lane, key, deps, output, warnings) }) +func (r *lockedCheckpointRecorder) MergeSucceeded(lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact) error { + return r.call(func() error { return r.inner.MergeSucceeded(lane, key, deps, output) }) } func (r *lockedCheckpointRecorder) MergeRejected(lane, key string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { return r.call(func() error { return r.inner.MergeRejected(lane, key, deps, rejected) }) @@ -155,8 +155,8 @@ func (r *lockedCheckpointRecorder) MergeFailed(lane, key string, deps []Checkpoi func (r *lockedCheckpointRecorder) NormalizeRunning(lane, key string, deps []CheckpointFingerprint) error { return r.call(func() error { return r.inner.NormalizeRunning(lane, key, deps) }) } -func (r *lockedCheckpointRecorder) NormalizeSucceeded(lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { - return r.call(func() error { return r.inner.NormalizeSucceeded(lane, key, deps, output, warnings) }) +func (r *lockedCheckpointRecorder) NormalizeSucceeded(lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact) error { + return r.call(func() error { return r.inner.NormalizeSucceeded(lane, key, deps, output) }) } func (r *lockedCheckpointRecorder) NormalizeRejected(lane, key string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { return r.call(func() error { return r.inner.NormalizeRejected(lane, key, deps, rejected) }) @@ -173,12 +173,12 @@ func (r *lockedCheckpointRecorder) ExtractRunningForStep(step, lane, key string, return r.inner.ExtractRunning(lane, key, deps) }) } -func (r *lockedCheckpointRecorder) ExtractSucceededForStep(step, lane, key string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { +func (r *lockedCheckpointRecorder) ExtractSucceededForStep(step, lane, key string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput) error { return r.call(func() error { if v, ok := r.inner.(StepCheckpointRecorder); ok { - return v.ExtractSucceededForStep(step, lane, key, deps, outputs, rejected, warnings) + return v.ExtractSucceededForStep(step, lane, key, deps, outputs, rejected) } - return r.inner.ExtractSucceeded(lane, key, deps, outputs, rejected, warnings) + return r.inner.ExtractSucceeded(lane, key, deps, outputs, rejected) }) } func (r *lockedCheckpointRecorder) ExtractFailedForStep(step, lane, key string, deps []CheckpointFingerprint, err error) error { @@ -197,12 +197,12 @@ func (r *lockedCheckpointRecorder) MergeRunningForStep(step, lane, key string, d return r.inner.MergeRunning(lane, key, deps) }) } -func (r *lockedCheckpointRecorder) MergeSucceededForStep(step, lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { +func (r *lockedCheckpointRecorder) MergeSucceededForStep(step, lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact) error { return r.call(func() error { if v, ok := r.inner.(StepCheckpointRecorder); ok { - return v.MergeSucceededForStep(step, lane, key, deps, output, warnings) + return v.MergeSucceededForStep(step, lane, key, deps, output) } - return r.inner.MergeSucceeded(lane, key, deps, output, warnings) + return r.inner.MergeSucceeded(lane, key, deps, output) }) } func (r *lockedCheckpointRecorder) MergeRejectedForStep(step, lane, key string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { @@ -229,12 +229,12 @@ func (r *lockedCheckpointRecorder) NormalizeRunningForStep(step, lane, key strin return r.inner.NormalizeRunning(lane, key, deps) }) } -func (r *lockedCheckpointRecorder) NormalizeSucceededForStep(step, lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { +func (r *lockedCheckpointRecorder) NormalizeSucceededForStep(step, lane, key string, deps []CheckpointFingerprint, output CheckpointArtifact) error { return r.call(func() error { if v, ok := r.inner.(StepCheckpointRecorder); ok { - return v.NormalizeSucceededForStep(step, lane, key, deps, output, warnings) + return v.NormalizeSucceededForStep(step, lane, key, deps, output) } - return r.inner.NormalizeSucceeded(lane, key, deps, output, warnings) + return r.inner.NormalizeSucceeded(lane, key, deps, output) }) } func (r *lockedCheckpointRecorder) NormalizeRejectedForStep(step, lane, key string, deps []CheckpointFingerprint, rejected contracts.RejectedOutput) error { diff --git a/internal/framework/pipeline/typed_execution.go b/internal/framework/pipeline/typed_execution.go index 58766eae..5332b488 100644 --- a/internal/framework/pipeline/typed_execution.go +++ b/internal/framework/pipeline/typed_execution.go @@ -23,7 +23,6 @@ type erasedMergeArtifact struct { type erasedTypedResult struct { Value any - Warnings []contracts.Warning Diagnostics []contracts.ProducerDiagnostic Retry *contracts.NormalizeRetry ModelCandidate *contracts.ModelCandidate diff --git a/internal/framework/pipeline/validation_executor.go b/internal/framework/pipeline/validation_executor.go index c2a55b5b..066034fa 100644 --- a/internal/framework/pipeline/validation_executor.go +++ b/internal/framework/pipeline/validation_executor.go @@ -29,14 +29,12 @@ type validationRecord struct { reasonCode string message string diagnosticPath string - warnings []contracts.Warning diagnostics []contracts.ProducerDiagnostic correctionGuidance string failure error } func (record validationRecord) clone() validationRecord { - record.warnings = cloneWarnings(record.warnings) record.diagnostics = contracts.CloneProducerDiagnostics(record.diagnostics) return record } @@ -54,16 +52,6 @@ func (report validationReport) Records() []validationRecord { return records } -func (report validationReport) Warnings() []contracts.Warning { - var warnings []contracts.Warning - for _, record := range report.records { - if record.outcome == validationApproved || record.outcome == validationRejected { - warnings = append(warnings, cloneWarnings(record.warnings)...) - } - } - return warnings -} - func (report validationReport) Diagnostics() []validationDiagnosticRecord { var diagnostics []validationDiagnosticRecord for _, record := range report.records { @@ -207,14 +195,14 @@ func executeValidationChain(ctx context.Context, chain preparedValidatorChain, i continue } if invocation.result.Approved { - report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationApproved, attemptCount: attempt, warnings: cloneWarnings(invocation.result.Warnings), diagnostics: cloneProducerDiagnostics(invocation.result.Diagnostics), diagnosticPath: invocation.result.DiagnosticArtifactPath}) + report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationApproved, attemptCount: attempt, diagnostics: cloneProducerDiagnostics(invocation.result.Diagnostics), diagnosticPath: invocation.result.DiagnosticArtifactPath}) break } message := invocation.result.Message if message == "" { message = "output rejected" } - report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationRejected, attemptCount: attempt, reasonCode: invocation.result.ReasonCode, message: message, diagnosticPath: invocation.result.DiagnosticArtifactPath, warnings: cloneWarnings(invocation.result.Warnings), diagnostics: cloneProducerDiagnostics(invocation.result.Diagnostics), correctionGuidance: invocation.result.CorrectionGuidance}) + report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationRejected, attemptCount: attempt, reasonCode: invocation.result.ReasonCode, message: message, diagnosticPath: invocation.result.DiagnosticArtifactPath, diagnostics: cloneProducerDiagnostics(invocation.result.Diagnostics), correctionGuidance: invocation.result.CorrectionGuidance}) break } } diff --git a/internal/framework/pipeline/validation_executor_test.go b/internal/framework/pipeline/validation_executor_test.go index c288a592..e0acfcf8 100644 --- a/internal/framework/pipeline/validation_executor_test.go +++ b/internal/framework/pipeline/validation_executor_test.go @@ -12,24 +12,24 @@ import ( func TestExecuteValidationChainSettlesEveryValidatorInOrder(t *testing.T) { tests := []struct { - name string - chain preparedValidatorChain - invoke validationInvoker - want []validationOutcome - wantCalls []string - wantGuidance []string - wantWarnings []string + name string + chain preparedValidatorChain + invoke validationInvoker + want []validationOutcome + wantCalls []string + wantGuidance []string + wantDiagnostics []string }{ { name: "all approved", chain: validationChain(validationSpec("shape", contracts.ExecutionClassDeterministic, 0), validationSpec("refs", contracts.ExecutionClassDeterministic, 0)), invoke: validationSequence(map[string][]validationStep{ - "shape": {{result: contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{ReasonCode: "shape"}}}}}, - "refs": {{result: contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{ReasonCode: "refs"}}}}}, + "shape": {{result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{validationDiagnostic("shape")}}}}, + "refs": {{result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{validationDiagnostic("refs")}}}}, }), - want: []validationOutcome{validationApproved, validationApproved}, - wantCalls: []string{"shape:1", "refs:1"}, - wantWarnings: []string{"shape", "refs"}, + want: []validationOutcome{validationApproved, validationApproved}, + wantCalls: []string{"shape:1", "refs:1"}, + wantDiagnostics: []string{"shape", "refs"}, }, { name: "multiple rejections deduplicate guidance", @@ -117,13 +117,13 @@ func TestExecuteValidationChainSettlesEveryValidatorInOrder(t *testing.T) { } } } - warnings := report.Warnings() - var warningCodes []string - for _, warning := range warnings { - warningCodes = append(warningCodes, warning.ReasonCode) + diagnostics := report.Diagnostics() + var diagnosticCodes []string + for _, record := range diagnostics { + diagnosticCodes = append(diagnosticCodes, record.diagnostic.ReasonCode) } - if !reflect.DeepEqual(warningCodes, test.wantWarnings) { - t.Fatalf("Warnings() = %#v, want codes %#v", warnings, test.wantWarnings) + if !reflect.DeepEqual(diagnosticCodes, test.wantDiagnostics) { + t.Fatalf("Diagnostics() = %#v, want codes %#v", diagnostics, test.wantDiagnostics) } }) } @@ -158,14 +158,24 @@ func TestValidationReportRejectsOversizedGuidanceAndOwnsRecords(t *testing.T) { unique[0] = byte('a' + index) report.records[index] = validationRecord{validatorName: "validator", outcome: validationRejected, correctionGuidance: string(unique)} } - report.records[0].warnings = []contracts.Warning{{ReasonCode: "warning"}} + report.records[0].diagnostics = []contracts.ProducerDiagnostic{validationDiagnostic("diagnostic")} if _, err := report.CorrectionRequest(); err == nil { t.Fatal("CorrectionRequest() error = nil, want aggregate overflow error") } records := report.Records() - records[0].warnings[0].ReasonCode = "changed" - if report.Records()[0].warnings[0].ReasonCode != "warning" { - t.Fatal("Records() exposed mutable warning storage") + records[0].diagnostics[0].ReasonCode = "changed" + if report.Records()[0].diagnostics[0].ReasonCode != "diagnostic" { + t.Fatal("Records() exposed mutable diagnostic storage") + } +} + +func validationDiagnostic(reasonCode string) contracts.ProducerDiagnostic { + return contracts.ProducerDiagnostic{ + Disposition: contracts.DiagnosticDispositionObservation, + Category: contracts.DiagnosticCategoryNormalization, + ReasonCode: reasonCode, + OccurrenceCount: 1, + Samples: []contracts.DiagnosticSample{{Scope: "validator", Message: reasonCode}}, } } diff --git a/internal/modules/dnd/chunk/scenes/chunker_test.go b/internal/modules/dnd/chunk/scenes/chunker_test.go index 46eb96cc..e2652d93 100644 --- a/internal/modules/dnd/chunk/scenes/chunker_test.go +++ b/internal/modules/dnd/chunk/scenes/chunker_test.go @@ -179,9 +179,6 @@ func TestPlanReturnsAnnotationFreeSceneRangesFromStructuredOutput(t *testing.T) if len(result.Plan.Annotations) != 0 { t.Fatalf("plan annotations = %#v, want absent", result.Plan.Annotations) } - if len(result.Warnings) != 0 { - t.Fatalf("warnings = %#v, want absent", result.Warnings) - } wantCandidate, err := json.Marshal(client.response) if err != nil { t.Fatalf("marshal expected candidate: %v", err) diff --git a/internal/modules/dnd/extract/combatturns/extractor_test.go b/internal/modules/dnd/extract/combatturns/extractor_test.go index 0fbc23e3..8ba7c4c8 100644 --- a/internal/modules/dnd/extract/combatturns/extractor_test.go +++ b/internal/modules/dnd/extract/combatturns/extractor_test.go @@ -310,10 +310,10 @@ func TestExtractAppliesSceneEligibilityBeforePromptConstruction(t *testing.T) { t.Fatal("CombatTurns = nil, want accepted non-nil empty list") } if test.wantWarning != "" { - if len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].Samples[0].Scope != SceneDescriptionReferenceSlot || result.Diagnostics[0].ReasonCode != test.wantWarning || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionWarning || result.Diagnostics[0].Category != contracts.DiagnosticCategoryDegradation { + if len(result.Diagnostics) != 1 || result.Diagnostics[0].Samples[0].Scope != SceneDescriptionReferenceSlot || result.Diagnostics[0].ReasonCode != test.wantWarning || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionWarning || result.Diagnostics[0].Category != contracts.DiagnosticCategoryDegradation { t.Fatalf("diagnostics = %#v", result.Diagnostics) } - } else if len(result.Warnings) != 0 || len(result.Diagnostics) != 0 { + } else if len(result.Diagnostics) != 0 { t.Fatalf("diagnostics = %#v, want none", result.Diagnostics) } }) diff --git a/internal/modules/dnd/extract/enemyevents/extractor_test.go b/internal/modules/dnd/extract/enemyevents/extractor_test.go index d105f9b5..ee06dab5 100644 --- a/internal/modules/dnd/extract/enemyevents/extractor_test.go +++ b/internal/modules/dnd/extract/enemyevents/extractor_test.go @@ -98,10 +98,10 @@ func TestExtractSkipsModelForIneligibleScenes(t *testing.T) { t.Fatalf("result = %#v, calls = %d", result, len(client.requests)) } if test.wantWarning { - if len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != "scene_classification_unavailable" || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionWarning || result.Diagnostics[0].Category != contracts.DiagnosticCategoryDegradation || result.Diagnostics[0].Samples[0].Scope != SceneDescriptionReferenceSlot { + if len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != "scene_classification_unavailable" || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionWarning || result.Diagnostics[0].Category != contracts.DiagnosticCategoryDegradation || result.Diagnostics[0].Samples[0].Scope != SceneDescriptionReferenceSlot { t.Fatalf("diagnostics = %#v", result.Diagnostics) } - } else if len(result.Warnings) != 0 || len(result.Diagnostics) != 0 { + } else if len(result.Diagnostics) != 0 { t.Fatalf("diagnostics = %#v", result.Diagnostics) } }) diff --git a/internal/modules/dnd/extract/spells/extractor_test.go b/internal/modules/dnd/extract/spells/extractor_test.go index 3148af7a..77cc3d1d 100644 --- a/internal/modules/dnd/extract/spells/extractor_test.go +++ b/internal/modules/dnd/extract/spells/extractor_test.go @@ -40,10 +40,6 @@ func TestExtractReturnsCanonicalSpellListFromPrivateResponse(t *testing.T) { if !reflect.DeepEqual(result.Value, want) { t.Fatalf("Value = %#v, want %#v", result.Value, want) } - if len(result.Warnings) != 0 { - t.Fatalf("Warnings = %#v, want none", result.Warnings) - } - if len(client.requests) != 1 { t.Fatalf("LLM calls = %d, want 1", len(client.requests)) } diff --git a/internal/modules/dnd/normalize/combatturns/normalizer.go b/internal/modules/dnd/normalize/combatturns/normalizer.go index 779ba031..c8ac8dc2 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer.go @@ -132,13 +132,13 @@ type actorCanonicalization struct { to string } -func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) { +func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []diagnostics.Finding) { if input.CombatTurns == nil { return dnd.CombatTurnList{}, nil } records := make([]normalizedRecord, len(input.CombatTurns)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, inputTurn := range input.CombatTurns { turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry) earliest, hasEvidence := order.EarliestValid(turn.SourceRefs) @@ -149,7 +149,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, hasEvidence: hasEvidence, } if actorChange != nil { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: turnScope(index), ReasonCode: ReasonCodeActorCanonicalized, Message: fmt.Sprintf("input index %d: actor canonicalized from %s to %s", @@ -157,7 +157,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, }) } if refsChanged { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: turnScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -179,7 +179,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, if position == record.inputIndex { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: turnScope(record.inputIndex), ReasonCode: ReasonCodeTurnsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", @@ -236,7 +236,7 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.CombatTurn, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.CombatTurn, []diagnostics.Finding) { if len(records) == 0 { return make([]dnd.CombatTurn, 0), nil } @@ -267,7 +267,7 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen } } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) == 0 { continue @@ -309,12 +309,12 @@ func writeKeyInt(builder *strings.Builder, value int) { builder.WriteByte(';') } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) } - return contracts.Warning{ + return diagnostics.Finding{ Scope: turnScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( diff --git a/internal/modules/dnd/normalize/combatturns/normalizer_test.go b/internal/modules/dnd/normalize/combatturns/normalizer_test.go index 342d8fb3..857fb73b 100644 --- a/internal/modules/dnd/normalize/combatturns/normalizer_test.go +++ b/internal/modules/dnd/normalize/combatturns/normalizer_test.go @@ -15,7 +15,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) { @@ -61,7 +60,7 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) { } func TestNormalizeLimitsWarningsWithoutChangingCombatTurnValues(t *testing.T) { - units := make([]source.SourceUnit, diagnostics.MaxWarnings+1) + units := make([]source.SourceUnit, contracts.MaxDiagnosticSamples+1) turns := make([]dnd.CombatTurn, len(units)) for index := range units { unitID := index + 1 @@ -82,7 +81,7 @@ func TestNormalizeLimitsWarningsWithoutChangingCombatTurnValues(t *testing.T) { if len(result.Value.CombatTurns) != len(turns) || result.Value.CombatTurns[0].Actor != "Aria" { t.Fatalf("normalized turns = %#v, want canonicalized values", result.Value.CombatTurns) } - if len(result.Warnings) != 0 || len(result.Diagnostics) == 0 || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionObservation || result.Diagnostics[0].OccurrenceCount != len(turns) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if len(result.Diagnostics) == 0 || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionObservation || result.Diagnostics[0].OccurrenceCount != len(turns) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("diagnostics = %#v", result.Diagnostics) } } diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer.go b/internal/modules/dnd/normalize/enemyevents/normalizer.go index e60a4165..0b619826 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer.go @@ -123,17 +123,17 @@ type nameCanonicalization struct { to string } -func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEventList, []contracts.Warning) { +func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEventList, []diagnostics.Finding) { if input.Events == nil { return dnd.EnemyEventList{}, nil } records := make([]normalizedRecord, len(input.Events)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, inputEvent := range input.Events { event, nameChange, refsChanged := normalizeEvent(inputEvent, order, registry) records[index] = normalizedRecord{event: event, identity: enemyeventmodel.CanonicalIdentity(event), inputIndex: index} if nameChange != nil { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: eventScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: subject canonicalized from %s to %s", @@ -141,7 +141,7 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist }) } if refsChanged { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: eventScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -157,7 +157,7 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist if position == record.inputIndex { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: eventScope(record.inputIndex), ReasonCode: ReasonCodeEventsReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position), @@ -208,7 +208,7 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []diagnostics.Finding) { if len(records) == 0 { return make([]dnd.EnemyEvent, 0), nil } @@ -235,7 +235,7 @@ func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []contrac for index, record := range kept { output[index] = cloneEvent(record.event) } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) == 0 { continue @@ -244,7 +244,7 @@ func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []contrac for index, removed := range group.removed { issues[index] = fmt.Sprintf("removed input index %d", removed) } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: eventScope(group.retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( diff --git a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go index 6974cacd..d9537a9d 100644 --- a/internal/modules/dnd/normalize/enemyevents/normalizer_test.go +++ b/internal/modules/dnd/normalize/enemyevents/normalizer_test.go @@ -13,7 +13,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeCanonicalizesSubjectsEvidenceOrderAndDuplicates(t *testing.T) { @@ -162,7 +161,7 @@ func TestNormalizerContractAndWarningBound(t *testing.T) { t.Fatalf("unsafe metadata = %s, %v", encoded, err) } - count := diagnostics.MaxWarnings + 5 + count := contracts.MaxDiagnosticSamples + 5 document := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.EnemyEventList{Events: make([]dnd.EnemyEvent, count)} for index := range document.Units { @@ -170,7 +169,7 @@ func TestNormalizerContractAndWarningBound(t *testing.T) { input.Events[index] = dnd.EnemyEvent{Name: " ÁRIA ", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: document.ID, StartUnitID: count - index, EndUnitID: count - index}}} } result, err := normalizer.Normalize(context.Background(), normalizeRequest(document, input, contracts.ReferenceSet{})) - if err != nil || len(result.Warnings) != 0 || len(result.Diagnostics) == 0 { + if err != nil || len(result.Diagnostics) == 0 { t.Fatalf("diagnostics = %#v, %v", result.Diagnostics, err) } } diff --git a/internal/modules/dnd/normalize/itemoccurrences/normalizer.go b/internal/modules/dnd/normalize/itemoccurrences/normalizer.go index c8423a0f..e37dc42d 100644 --- a/internal/modules/dnd/normalize/itemoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/itemoccurrences/normalizer.go @@ -120,18 +120,18 @@ type normalizedRecord struct { inputIndex int } -func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, []contracts.Warning) { +func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, []diagnostics.Finding) { if input.Occurrences == nil { return dnd.ItemOccurrenceList{}, nil } records := make([]normalizedRecord, len(input.Occurrences)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, inputOccurrence := range input.Occurrences { occurrence, changedFields, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry) records[index] = normalizedRecord{occurrence: occurrence, identity: itemoccurrencemodel.CanonicalExactIdentity(occurrence), inputIndex: index} if len(changedFields) != 0 { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index, @@ -139,15 +139,15 @@ func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, ord }) } if found && inputOccurrence.Name != occurrence.Name { - warnings = append(warnings, contracts.Warning{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, + warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: item name canonicalized from %s to %s", index, diagnostics.Quote(inputOccurrence.Name), diagnostics.Quote(occurrence.Name))}) } if !found { - warnings = append(warnings, contracts.Warning{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownItemID, + warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownItemID, Message: fmt.Sprintf("input index %d: item ID %s is not in the supplied registry", index, diagnostics.Quote(inputOccurrence.ItemID))}) } if refsChanged { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -163,7 +163,7 @@ func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, ord if position == record.inputIndex { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position), @@ -217,7 +217,7 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex) ([]dnd.ItemOccurrence, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex) ([]dnd.ItemOccurrence, []diagnostics.Finding) { if len(records) == 0 { return make([]dnd.ItemOccurrence, 0), nil } @@ -253,7 +253,7 @@ func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex) output = append(output, cloneOccurrence(record.occurrence)) } } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) != 0 { warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) @@ -262,12 +262,12 @@ func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex) return output, warnings } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) } - return contracts.Warning{ + return diagnostics.Finding{ Scope: occurrenceScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( diff --git a/internal/modules/dnd/normalize/itemregistry/normalizer.go b/internal/modules/dnd/normalize/itemregistry/normalizer.go index 57058925..bbdc154b 100644 --- a/internal/modules/dnd/normalize/itemregistry/normalizer.go +++ b/internal/modules/dnd/normalize/itemregistry/normalizer.go @@ -149,11 +149,11 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize return retryResult(recordList(applied), findings, advisoryFindings, reconciliation, rejectedGroups) } -func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, findings []contracts.Warning) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { +func (n *Normalizer) invalidStructuredResult(value dnd.ItemRegistry, findings []diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { return retryResultWithFallback(value, findings, nil, nil, ReasonCodeItemSemanticRetryProposalInvalid, "semantic proposal requires retry: invalid structured output", semanticFallbackFinding(-1)) } -func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []contracts.Warning, reconciliation semanticreconcile.Result, rejectedGroups int) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { +func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostics.Finding, reconciliation semanticreconcile.Result, rejectedGroups int) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { details := semanticreconcile.IssueDetails(reconciliation.Issues()) if rejectedGroups > 0 { details = append(details, "currency may only be consolidated with aliases of one denomination") @@ -162,15 +162,15 @@ func retryResult(value dnd.ItemRegistry, findings, advisoryFindings []contracts. return retryResultWithFallback(value, findings, advisoryFindings, reconciliation.ModelCandidate(), ReasonCodeItemSemanticRetryProposalInvalid, diagnostics.Aggregate("semantic proposal requires retry", details), semanticFallbackFinding(discardedGroups)) } -func semanticFallbackFinding(discarded int) contracts.Warning { +func semanticFallbackFinding(discarded int) diagnostics.Finding { message := "semantic proposal could not be applied" if discarded >= 0 { message = fmt.Sprintf("%d proposal group(s) omitted after semantic proposal retry exhaustion", discarded) } - return contracts.Warning{Scope: "items", ReasonCode: ReasonCodeItemSemanticReconciliationExhausted, Message: message} + return diagnostics.Finding{Scope: "items", ReasonCode: ReasonCodeItemSemanticReconciliationExhausted, Message: message} } -func normalizationResult(value dnd.ItemRegistry, findings, advisoryFindings []contracts.Warning, candidate *contracts.ModelCandidate) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { +func normalizationResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostics.Finding, candidate *contracts.ModelCandidate) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { diagnosticGroups, err := diagnostics.Collect(findings, contracts.DiagnosticDispositionObservation, contracts.DiagnosticCategoryNormalization) if err != nil { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("collect normalization diagnostics: %w", err) @@ -183,12 +183,12 @@ func normalizationResult(value dnd.ItemRegistry, findings, advisoryFindings []co return contracts.TypedNormalizeResult[dnd.ItemRegistry]{Value: value, Diagnostics: diagnosticGroups, ModelCandidate: candidate}, nil } -func fallbackResult(value dnd.ItemRegistry, findings, advisoryFindings []contracts.Warning, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { +func fallbackResult(value dnd.ItemRegistry, findings, advisoryFindings []diagnostics.Finding, fallback diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { result, err := normalizationResult(value, findings, advisoryFindings, nil) if err != nil { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, err } - fallbackGroups, err := diagnostics.Collect([]contracts.Warning{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) + fallbackGroups, err := diagnostics.Collect([]diagnostics.Finding{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) if err != nil { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err) } @@ -196,12 +196,12 @@ func fallbackResult(value dnd.ItemRegistry, findings, advisoryFindings []contrac return result, nil } -func retryResultWithFallback(value dnd.ItemRegistry, findings, advisoryFindings []contracts.Warning, candidate *contracts.ModelCandidate, reasonCode, message string, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { +func retryResultWithFallback(value dnd.ItemRegistry, findings, advisoryFindings []diagnostics.Finding, candidate *contracts.ModelCandidate, reasonCode, message string, fallback diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.ItemRegistry], error) { result, err := normalizationResult(value, findings, advisoryFindings, candidate) if err != nil { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, err } - fallbackGroups, err := diagnostics.Collect([]contracts.Warning{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) + fallbackGroups, err := diagnostics.Collect([]diagnostics.Finding{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) if err != nil { return contracts.TypedNormalizeResult[dnd.ItemRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err) } @@ -215,23 +215,23 @@ type normalizedRecord struct { earliest int } -func preprocessRecords(input dnd.ItemRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) { +func preprocessRecords(input dnd.ItemRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []diagnostics.Finding) { if input.Items == nil { return nil, nil } records := make([]normalizedRecord, len(input.Items)) - findings := make([]contracts.Warning, 0) + findings := make([]diagnostics.Finding, 0) for index, inputItem := range input.Items { item, fieldsChanged, refsChanged := normalizeRecord(inputItem, order) records[index] = normalizedRecord{item: item, inputIndexes: []int{index}, earliest: index} if fieldsChanged { - findings = append(findings, contracts.Warning{Scope: itemScope(index), ReasonCode: ReasonCodeItemFieldsNormalized, Message: fmt.Sprintf("input index %d: item name normalized for %s", index, diagnostics.Quote(inputItem.Name))}) + findings = append(findings, diagnostics.Finding{Scope: itemScope(index), ReasonCode: ReasonCodeItemFieldsNormalized, Message: fmt.Sprintf("input index %d: item name normalized for %s", index, diagnostics.Quote(inputItem.Name))}) } if refsChanged { - findings = append(findings, contracts.Warning{Scope: itemScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputItem.SourceRefs), len(item.SourceRefs))}) + findings = append(findings, diagnostics.Finding{Scope: itemScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputItem.SourceRefs), len(item.SourceRefs))}) } if inputItem.ID != item.ID { - findings = append(findings, contracts.Warning{Scope: itemScope(index), ReasonCode: ReasonCodeItemIDRecomputed, Message: fmt.Sprintf("input index %d: item ID recomputed from %s", index, diagnostics.Quote(item.Name))}) + findings = append(findings, diagnostics.Finding{Scope: itemScope(index), ReasonCode: ReasonCodeItemIDRecomputed, Message: fmt.Sprintf("input index %d: item ID recomputed from %s", index, diagnostics.Quote(item.Name))}) } } groups := comparisonNameGroups(records) @@ -328,7 +328,7 @@ func recordList(records []normalizedRecord) dnd.ItemRegistry { return dnd.ItemRegistry{Items: recordValues(records)} } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { @@ -342,7 +342,7 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { if omitted := len(removed) - len(displayed); omitted > 0 { message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) } - return contracts.Warning{Scope: itemScope(retainedIndex), ReasonCode: ReasonCodeDuplicateItemCollapsed, Message: message} + return diagnostics.Finding{Scope: itemScope(retainedIndex), ReasonCode: ReasonCodeDuplicateItemCollapsed, Message: message} } func itemScope(index int) string { return fmt.Sprintf("items[%d]", index) } diff --git a/internal/modules/dnd/normalize/itemregistry/reconciliation.go b/internal/modules/dnd/normalize/itemregistry/reconciliation.go index d78ecee7..81ae8756 100644 --- a/internal/modules/dnd/normalize/itemregistry/reconciliation.go +++ b/internal/modules/dnd/normalize/itemregistry/reconciliation.go @@ -4,7 +4,6 @@ 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" @@ -31,7 +30,7 @@ func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candi return candidates, envelopes, nil } -func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Item], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, []contracts.Warning, int, error) { +func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Item], order shared.SourceRefOrder) ([]normalizedRecord, []diagnostics.Finding, []diagnostics.Finding, int, error) { application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Item]{ CloneValue: cloneItem, RejectGroup: func(members []dnd.Item, _ dnd.Item) semanticreconcile.RejectionCategory { @@ -66,7 +65,7 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe } type orderedFinding struct { position int - finding contracts.Warning + finding diagnostics.Finding } observations := make([]orderedFinding, 0, len(application.AppliedGroups())) advisories := make([]orderedFinding, 0, len(application.RejectedGroups())) @@ -81,7 +80,7 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe provenance := event.Provenance() advisories = append(advisories, orderedFinding{ position: provenance.EarliestInputPosition(), - finding: contracts.Warning{ + finding: diagnostics.Finding{ Scope: itemScope(provenance.EarliestInputPosition()), ReasonCode: ReasonCodeItemSemanticProposalInvalid, Message: "proposal group preserved because currency may only be consolidated with aliases of one denomination", @@ -90,11 +89,11 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe } sort.SliceStable(observations, func(left, right int) bool { return observations[left].position < observations[right].position }) sort.SliceStable(advisories, func(left, right int) bool { return advisories[left].position < advisories[right].position }) - observationFindings := make([]contracts.Warning, len(observations)) + observationFindings := make([]diagnostics.Finding, len(observations)) for index, entry := range observations { observationFindings[index] = entry.finding } - advisoryFindings := make([]contracts.Warning, len(advisories)) + advisoryFindings := make([]diagnostics.Finding, len(advisories)) for index, entry := range advisories { advisoryFindings[index] = entry.finding } @@ -139,7 +138,7 @@ func currencyDenomination(name string) string { } } -func semanticDuplicateFinding(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning { +func semanticDuplicateFinding(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding { inputIndexes := provenance.OriginalInputIndexes() details := make([]string, 0, len(inputIndexes)+1) for _, inputIndex := range inputIndexes { @@ -148,7 +147,7 @@ func semanticDuplicateFinding(provenance semanticreconcile.GroupProvenance, cano if canonical.earliest != provenance.EarliestInputPosition() { details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest)) } - return contracts.Warning{ + return diagnostics.Finding{ Scope: itemScope(provenance.EarliestInputPosition()), ReasonCode: ReasonCodeDuplicateItemCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details), diff --git a/internal/modules/dnd/normalize/locationoccurrences/normalizer.go b/internal/modules/dnd/normalize/locationoccurrences/normalizer.go index a57d8ce5..5f751424 100644 --- a/internal/modules/dnd/normalize/locationoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/locationoccurrences/normalizer.go @@ -122,25 +122,25 @@ type normalizedRecord struct { type nameCanonicalization struct{ from, to string } -func normalizeList(input dnd.LocationOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *locationregistry.Registry) (dnd.LocationOccurrenceList, []contracts.Warning) { +func normalizeList(input dnd.LocationOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *locationregistry.Registry) (dnd.LocationOccurrenceList, []diagnostics.Finding) { if input.Occurrences == nil { return dnd.LocationOccurrenceList{}, nil } records := make([]normalizedRecord, len(input.Occurrences)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, inputOccurrence := range input.Occurrences { occurrence, change, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry) records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index} if change != nil { - warnings = append(warnings, contracts.Warning{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, + warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized, Message: fmt.Sprintf("input index %d: location name canonicalized from %s to %s", index, diagnostics.Quote(change.from), diagnostics.Quote(change.to))}) } if !found { - warnings = append(warnings, contracts.Warning{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownLocationID, + warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownLocationID, Message: fmt.Sprintf("input index %d: location ID %s is not in the supplied registry", index, diagnostics.Quote(inputOccurrence.LocationID))}) } if refsChanged { - warnings = append(warnings, contracts.Warning{Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, + warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputOccurrence.SourceRefs), len(occurrence.SourceRefs))}) } } @@ -149,7 +149,7 @@ func normalizeList(input dnd.LocationOccurrenceList, documentIndex source.Docume }) for position, record := range records { if position != record.inputIndex { - warnings = append(warnings, contracts.Warning{Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, + warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by canonical occurrence order", record.inputIndex, position)}) } } @@ -194,7 +194,7 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.LocationOccurrence, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.LocationOccurrence, []diagnostics.Finding) { if len(records) == 0 { return make([]dnd.LocationOccurrence, 0), nil } @@ -222,7 +222,7 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen output = append(output, cloneOccurrence(record.occurrence)) } } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) > 0 { warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) @@ -316,12 +316,12 @@ func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef) return len(left) < len(right) } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) } - return contracts.Warning{Scope: occurrenceScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, + return diagnostics.Finding{Scope: occurrenceScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate(fmt.Sprintf("duplicate location occurrence collapsed; retained input index %d", retainedIndex), issues)} } diff --git a/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go b/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go index 4c3c9688..89c5fff7 100644 --- a/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go +++ b/internal/modules/dnd/normalize/locationoccurrences/normalizer_test.go @@ -12,7 +12,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeCanonicalizesNamesByIDAndClonesInputs(t *testing.T) { @@ -125,7 +124,7 @@ func TestNormalizerContractsRequiredRegistryAndWarningBounds(t *testing.T) { t.Fatalf("unbound registry error = %v", err) } - count := diagnostics.MaxWarnings + 5 + count := contracts.MaxDiagnosticSamples + 5 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.LocationOccurrenceList{Occurrences: make([]dnd.LocationOccurrence, count)} location := registryLocations("The Mill").Locations[0] @@ -134,7 +133,7 @@ func TestNormalizerContractsRequiredRegistryAndWarningBounds(t *testing.T) { input.Occurrences[index] = dnd.LocationOccurrence{LocationID: location.ID, Name: "not canonical", Kind: dnd.LocationOccurrenceKindMentioned, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: count - index, EndUnitID: count - index}}} } bounded, err := newNormalizer(t, registryReferences(t, dnd.LocationRegistry{Locations: []dnd.Location{location}})).Normalize(context.Background(), normalizeRequest(input, doc, contracts.ReferenceSet{})) - if err != nil || len(bounded.Warnings) != 0 || len(bounded.Diagnostics) == 0 { + if err != nil || len(bounded.Diagnostics) == 0 { t.Fatalf("bounded diagnostics = %#v, %v", bounded.Diagnostics, err) } } diff --git a/internal/modules/dnd/normalize/locationregistry/normalizer.go b/internal/modules/dnd/normalize/locationregistry/normalizer.go index 6f9296fe..99728b66 100644 --- a/internal/modules/dnd/normalize/locationregistry/normalizer.go +++ b/internal/modules/dnd/normalize/locationregistry/normalizer.go @@ -148,23 +148,23 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize return retryResult(recordList(applied), findings, reconciliation) } -func (n *Normalizer) invalidStructuredResult(value dnd.LocationRegistry, findings []contracts.Warning) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { +func (n *Normalizer) invalidStructuredResult(value dnd.LocationRegistry, findings []diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { return retryResultWithFallback(value, findings, nil, "semantic proposal requires retry: invalid structured output", semanticFallbackFinding(-1)) } -func retryResult(value dnd.LocationRegistry, findings []contracts.Warning, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { +func retryResult(value dnd.LocationRegistry, findings []diagnostics.Finding, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { return retryResultWithFallback(value, findings, reconciliation.ModelCandidate(), diagnostics.Aggregate("semantic proposal requires retry", semanticreconcile.IssueDetails(reconciliation.Issues())), semanticFallbackFinding(reconciliation.DiscardedGroupCount())) } -func semanticFallbackFinding(discarded int) contracts.Warning { +func semanticFallbackFinding(discarded int) diagnostics.Finding { message := "semantic proposal could not be applied" if discarded >= 0 { message = fmt.Sprintf("%d proposal group(s) omitted after semantic proposal retry exhaustion", discarded) } - return contracts.Warning{Scope: "locations", ReasonCode: ReasonCodeLocationSemanticReconciliationExhausted, Message: message} + return diagnostics.Finding{Scope: "locations", ReasonCode: ReasonCodeLocationSemanticReconciliationExhausted, Message: message} } -func normalizationResult(value dnd.LocationRegistry, findings []contracts.Warning, candidate *contracts.ModelCandidate) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { +func normalizationResult(value dnd.LocationRegistry, findings []diagnostics.Finding, candidate *contracts.ModelCandidate) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { diagnosticGroups, err := diagnostics.Collect(findings, contracts.DiagnosticDispositionObservation, contracts.DiagnosticCategoryNormalization) if err != nil { return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("collect normalization diagnostics: %w", err) @@ -172,12 +172,12 @@ func normalizationResult(value dnd.LocationRegistry, findings []contracts.Warnin return contracts.TypedNormalizeResult[dnd.LocationRegistry]{Value: value, Diagnostics: diagnosticGroups, ModelCandidate: candidate}, nil } -func fallbackResult(value dnd.LocationRegistry, findings []contracts.Warning, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { +func fallbackResult(value dnd.LocationRegistry, findings []diagnostics.Finding, fallback diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { result, err := normalizationResult(value, findings, nil) if err != nil { return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, err } - fallbackGroups, err := diagnostics.Collect([]contracts.Warning{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) + fallbackGroups, err := diagnostics.Collect([]diagnostics.Finding{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) if err != nil { return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err) } @@ -185,12 +185,12 @@ func fallbackResult(value dnd.LocationRegistry, findings []contracts.Warning, fa return result, nil } -func retryResultWithFallback(value dnd.LocationRegistry, findings []contracts.Warning, candidate *contracts.ModelCandidate, message string, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { +func retryResultWithFallback(value dnd.LocationRegistry, findings []diagnostics.Finding, candidate *contracts.ModelCandidate, message string, fallback diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.LocationRegistry], error) { result, err := normalizationResult(value, findings, candidate) if err != nil { return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, err } - fallbackGroups, err := diagnostics.Collect([]contracts.Warning{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) + fallbackGroups, err := diagnostics.Collect([]diagnostics.Finding{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) if err != nil { return contracts.TypedNormalizeResult[dnd.LocationRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err) } @@ -204,23 +204,23 @@ type normalizedRecord struct { earliest int } -func preprocessRecords(input dnd.LocationRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) { +func preprocessRecords(input dnd.LocationRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []diagnostics.Finding) { if input.Locations == nil { return nil, nil } records := make([]normalizedRecord, len(input.Locations)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, inputLocation := range input.Locations { location, fieldsChanged, refsChanged := normalizeRecord(inputLocation, order) records[index] = normalizedRecord{location: location, inputIndexes: []int{index}, earliest: index} if fieldsChanged { - warnings = append(warnings, contracts.Warning{Scope: locationScope(index), ReasonCode: ReasonCodeLocationFieldsNormalized, Message: fmt.Sprintf("input index %d: location name normalized for %s", index, diagnostics.Quote(inputLocation.Name))}) + warnings = append(warnings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeLocationFieldsNormalized, Message: fmt.Sprintf("input index %d: location name normalized for %s", index, diagnostics.Quote(inputLocation.Name))}) } if refsChanged { - warnings = append(warnings, contracts.Warning{Scope: locationScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputLocation.SourceRefs), len(location.SourceRefs))}) + warnings = append(warnings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputLocation.SourceRefs), len(location.SourceRefs))}) } if inputLocation.ID != location.ID { - warnings = append(warnings, contracts.Warning{Scope: locationScope(index), ReasonCode: ReasonCodeLocationIDRecomputed, Message: fmt.Sprintf("input index %d: location ID recomputed from %s", index, diagnostics.Quote(location.Name))}) + warnings = append(warnings, diagnostics.Finding{Scope: locationScope(index), ReasonCode: ReasonCodeLocationIDRecomputed, Message: fmt.Sprintf("input index %d: location ID recomputed from %s", index, diagnostics.Quote(location.Name))}) } } groups := exactDuplicateGroups(records) @@ -342,7 +342,7 @@ func recordList(records []normalizedRecord) dnd.LocationRegistry { return dnd.LocationRegistry{Locations: recordValues(records)} } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { @@ -356,7 +356,7 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { if omitted := len(removed) - len(displayed); omitted > 0 { message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) } - return contracts.Warning{Scope: locationScope(retainedIndex), ReasonCode: ReasonCodeDuplicateLocationCollapsed, Message: message} + return diagnostics.Finding{Scope: locationScope(retainedIndex), ReasonCode: ReasonCodeDuplicateLocationCollapsed, Message: message} } func locationScope(index int) string { return fmt.Sprintf("locations[%d]", index) } diff --git a/internal/modules/dnd/normalize/locationregistry/reconciliation.go b/internal/modules/dnd/normalize/locationregistry/reconciliation.go index ebeaa8ba..7d773e60 100644 --- a/internal/modules/dnd/normalize/locationregistry/reconciliation.go +++ b/internal/modules/dnd/normalize/locationregistry/reconciliation.go @@ -3,7 +3,6 @@ package locationregistry import ( "fmt" - "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/locations/identity" @@ -28,7 +27,7 @@ func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candi return candidates, envelopes, nil } -func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Location], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, error) { +func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.Location], order shared.SourceRefOrder) ([]normalizedRecord, []diagnostics.Finding, error) { application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.Location]{ CloneValue: cloneLocation, ConsolidateGroup: func(members []dnd.Location, canonical dnd.Location) (dnd.Location, error) { @@ -55,7 +54,7 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe earliest: record.EarliestInputPosition(), } } - warnings := make([]contracts.Warning, 0, len(application.AppliedGroups())) + warnings := make([]diagnostics.Finding, 0, len(application.AppliedGroups())) for _, event := range application.AppliedGroups() { provenance := event.Provenance() warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()])) @@ -63,7 +62,7 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe return output, warnings, nil } -func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning { +func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding { inputIndexes := provenance.OriginalInputIndexes() details := make([]string, 0, len(inputIndexes)+1) for _, inputIndex := range inputIndexes { @@ -72,7 +71,7 @@ func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, cano if canonical.earliest != provenance.EarliestInputPosition() { details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest)) } - return contracts.Warning{ + return diagnostics.Finding{ Scope: locationScope(provenance.EarliestInputPosition()), ReasonCode: ReasonCodeDuplicateLocationCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details), diff --git a/internal/modules/dnd/normalize/npcoccurrences/normalizer.go b/internal/modules/dnd/normalize/npcoccurrences/normalizer.go index d57c73ec..fa0f0295 100644 --- a/internal/modules/dnd/normalize/npcoccurrences/normalizer.go +++ b/internal/modules/dnd/normalize/npcoccurrences/normalizer.go @@ -124,13 +124,13 @@ type normalizedRecord struct { inputIndex int } -func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []contracts.Warning, error) { +func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, []diagnostics.Finding, error) { if input.Occurrences == nil { return dnd.NPCOccurrenceList{}, nil, nil } records := make([]normalizedRecord, len(input.Occurrences)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, inputOccurrence := range input.Occurrences { occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry) if err != nil { @@ -138,7 +138,7 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd } records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index} if refsChanged { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", @@ -154,7 +154,7 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd if position == record.inputIndex { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered, Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position), @@ -192,7 +192,7 @@ type duplicateGroup struct { removed []int } -func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCOccurrence, []contracts.Warning) { +func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCOccurrence, []diagnostics.Finding) { if len(records) == 0 { return make([]dnd.NPCOccurrence, 0), nil } @@ -220,7 +220,7 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen output = append(output, cloneOccurrence(record.occurrence)) } } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) != 0 { warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed)) @@ -229,12 +229,12 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen return output, warnings } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { issues := make([]string, len(removed)) for index, removedIndex := range removed { issues[index] = fmt.Sprintf("removed input index %d", removedIndex) } - return contracts.Warning{ + return diagnostics.Finding{ Scope: occurrenceScope(retainedIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: diagnostics.Aggregate( diff --git a/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go b/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go index 6bfcd0d9..cecfcd8f 100644 --- a/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go +++ b/internal/modules/dnd/normalize/npcoccurrences/normalizer_test.go @@ -11,7 +11,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcregistry" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeValidatesPairsAndClones(t *testing.T) { @@ -141,7 +140,7 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) { } func TestNormalizeBoundsWarnings(t *testing.T) { - count := diagnostics.MaxWarnings + 5 + count := contracts.MaxDiagnosticSamples + 5 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, count)} for index := range doc.Units { @@ -163,7 +162,7 @@ func TestNormalizeBoundsWarnings(t *testing.T) { if err != nil { t.Fatal(err) } - if len(result.Warnings) != 0 || len(result.Diagnostics) == 0 || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionObservation { + if len(result.Diagnostics) == 0 || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionObservation { t.Fatalf("diagnostics = %#v", result.Diagnostics) } } diff --git a/internal/modules/dnd/normalize/npcregistry/normalizer.go b/internal/modules/dnd/normalize/npcregistry/normalizer.go index c4f229d9..05b53b15 100644 --- a/internal/modules/dnd/normalize/npcregistry/normalizer.go +++ b/internal/modules/dnd/normalize/npcregistry/normalizer.go @@ -147,23 +147,23 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize return retryResult(recordList(applied), findings, reconciliation) } -func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, findings []contracts.Warning) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { +func (n *Normalizer) invalidStructuredResult(value dnd.NPCRegistry, findings []diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { return retryResultWithFallback(value, findings, nil, "semantic proposal requires retry: invalid structured output", semanticFallbackFinding(-1)) } -func retryResult(value dnd.NPCRegistry, findings []contracts.Warning, reconciliation semanticreconcile.Result) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { +func retryResult(value dnd.NPCRegistry, findings []diagnostics.Finding, 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 semanticFallbackFinding(discardedGroups int) contracts.Warning { +func semanticFallbackFinding(discardedGroups int) diagnostics.Finding { message := "semantic proposal could not be applied" if discardedGroups >= 0 { message = fmt.Sprintf("%d proposal group(s) omitted after semantic proposal retry exhaustion", discardedGroups) } - return contracts.Warning{Scope: "npcs", ReasonCode: ReasonCodeNPCSemanticReconciliationExhausted, Message: message} + return diagnostics.Finding{Scope: "npcs", ReasonCode: ReasonCodeNPCSemanticReconciliationExhausted, Message: message} } -func normalizationResult(value dnd.NPCRegistry, findings []contracts.Warning, candidate *contracts.ModelCandidate) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { +func normalizationResult(value dnd.NPCRegistry, findings []diagnostics.Finding, 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) @@ -171,12 +171,12 @@ func normalizationResult(value dnd.NPCRegistry, findings []contracts.Warning, ca return contracts.TypedNormalizeResult[dnd.NPCRegistry]{Value: value, Diagnostics: diagnosticGroups, ModelCandidate: candidate}, nil } -func fallbackResult(value dnd.NPCRegistry, findings []contracts.Warning, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { +func fallbackResult(value dnd.NPCRegistry, findings []diagnostics.Finding, fallback diagnostics.Finding) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { result, err := normalizationResult(value, findings, nil) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, err } - fallbackGroups, err := diagnostics.Collect([]contracts.Warning{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) + fallbackGroups, err := diagnostics.Collect([]diagnostics.Finding{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err) } @@ -184,12 +184,12 @@ func fallbackResult(value dnd.NPCRegistry, findings []contracts.Warning, fallbac return result, nil } -func retryResultWithFallback(value dnd.NPCRegistry, findings []contracts.Warning, candidate *contracts.ModelCandidate, message string, fallback contracts.Warning) (contracts.TypedNormalizeResult[dnd.NPCRegistry], error) { +func retryResultWithFallback(value dnd.NPCRegistry, findings []diagnostics.Finding, candidate *contracts.ModelCandidate, message string, fallback diagnostics.Finding) (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) + fallbackGroups, err := diagnostics.Collect([]diagnostics.Finding{fallback}, contracts.DiagnosticDispositionWarning, contracts.DiagnosticCategoryFallback) if err != nil { return contracts.TypedNormalizeResult[dnd.NPCRegistry]{}, normalizerErrorf("collect fallback diagnostic: %w", err) } @@ -203,23 +203,23 @@ type normalizedRecord struct { earliest int } -func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) { +func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]normalizedRecord, []diagnostics.Finding) { if input.NPCs == nil { return nil, nil } records := make([]normalizedRecord, len(input.NPCs)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, inputNPC := range input.NPCs { npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order) records[index] = normalizedRecord{npc: npc, inputIndexes: []int{index}, earliest: index} if fieldsChanged { - warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))}) + warnings = append(warnings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))}) } if referencesChanged { - warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))}) + warnings = append(warnings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputNPC.SourceRefs), len(npc.SourceRefs))}) } if inputNPC.ID != npc.ID { - warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))}) + warnings = append(warnings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))}) } } @@ -230,7 +230,7 @@ func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]no output = append(output, consolidated) retainedIndex := consolidated.earliest if referencesChanged { - warnings = append(warnings, contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.npc.SourceRefs))}) + warnings = append(warnings, diagnostics.Finding{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.npc.SourceRefs))}) } if len(members) > 1 { warnings = append(warnings, duplicateWarning(retainedIndex, memberInputIndexes(records, members[1:]))) @@ -340,7 +340,7 @@ func cloneSourceRefs(input []source.SourceRef) []source.SourceRef { return append([]source.SourceRef(nil), input...) } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { @@ -354,7 +354,7 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { if omitted := len(removed) - len(displayed); omitted > 0 { message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) } - return contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message} + return diagnostics.Finding{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: message} } func npcScope(index int) string { return fmt.Sprintf("npcs[%d]", index) } diff --git a/internal/modules/dnd/normalize/npcregistry/reconciliation.go b/internal/modules/dnd/normalize/npcregistry/reconciliation.go index a2232f1d..ea303a44 100644 --- a/internal/modules/dnd/normalize/npcregistry/reconciliation.go +++ b/internal/modules/dnd/normalize/npcregistry/reconciliation.go @@ -3,7 +3,6 @@ package npcregistry import ( "fmt" - "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/npcs/identity" @@ -28,7 +27,7 @@ func reconciliationInputs(records []normalizedRecord) ([]semanticreconcile.Candi return candidates, envelopes, nil } -func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.NPC], order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning, error) { +func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRecord, envelopes []semanticreconcile.Record[dnd.NPC], order shared.SourceRefOrder) ([]normalizedRecord, []diagnostics.Finding, error) { application, err := semanticreconcile.ApplyPlan(plan, envelopes, semanticreconcile.ApplicationPolicy[dnd.NPC]{ CloneValue: cloneNPC, ConsolidateGroup: func(members []dnd.NPC, canonical dnd.NPC) (dnd.NPC, error) { @@ -55,7 +54,7 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe earliest: record.EarliestInputPosition(), } } - warnings := make([]contracts.Warning, 0, len(application.AppliedGroups())) + warnings := make([]diagnostics.Finding, 0, len(application.AppliedGroups())) for _, event := range application.AppliedGroups() { provenance := event.Provenance() warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()])) @@ -63,7 +62,7 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe return output, warnings, nil } -func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) contracts.Warning { +func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding { inputIndexes := provenance.OriginalInputIndexes() details := make([]string, 0, len(inputIndexes)+1) for _, inputIndex := range inputIndexes { @@ -72,7 +71,7 @@ func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, cano if canonical.earliest != provenance.EarliestInputPosition() { details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest)) } - return contracts.Warning{ + return diagnostics.Finding{ Scope: npcScope(provenance.EarliestInputPosition()), ReasonCode: ReasonCodeDuplicateNPCCollapsed, Message: diagnostics.Aggregate("semantic duplicate consolidation", details), diff --git a/internal/modules/dnd/normalize/scenedescriptions/normalizer.go b/internal/modules/dnd/normalize/scenedescriptions/normalizer.go index f02a4152..2beb8c16 100644 --- a/internal/modules/dnd/normalize/scenedescriptions/normalizer.go +++ b/internal/modules/dnd/normalize/scenedescriptions/normalizer.go @@ -79,7 +79,7 @@ type normalizedScene struct { inputIndex int } -func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (dnd.SceneDescriptionList, []contracts.Warning, error) { +func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (dnd.SceneDescriptionList, []diagnostics.Finding, error) { if doc == nil { return dnd.SceneDescriptionList{}, nil, fmt.Errorf("source document must not be nil") } @@ -92,7 +92,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( documentIndex := source.NewDocumentIndex(doc) records := make([]normalizedScene, len(input.Scenes)) - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for sceneIndex, scene := range input.Scenes { originalTitle, originalSummary := scene.Title, scene.Summary scene.Title = strings.TrimSpace(scene.Title) @@ -104,7 +104,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scenes[%d].source_ref: %s", sceneIndex, diagnostics.Truncate(err.Error())) } if originalTitle != scene.Title || originalSummary != scene.Summary { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: sceneScope(sceneIndex), ReasonCode: ReasonCodeProseNormalized, Message: fmt.Sprintf("input index %d: title and/or summary whitespace normalized", sceneIndex), @@ -125,7 +125,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( if position == record.inputIndex { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: sceneScope(record.inputIndex), ReasonCode: ReasonCodeOrderNormalized, Message: fmt.Sprintf("input index %d moved to normalized position %d by canonical scene order", @@ -146,7 +146,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) ( return dnd.SceneDescriptionList{}, nil, fmt.Errorf("source range %s has conflicting records", sourceRefLabel(scene.SourceRef)) } if retainedIndex, ok := seen[scene]; ok { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: sceneScope(record.inputIndex), ReasonCode: ReasonCodeDuplicateCollapsed, Message: fmt.Sprintf("input index %d: exact duplicate scene collapsed; retained input index %d", diff --git a/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go b/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go index 36c61142..0027fd54 100644 --- a/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go +++ b/internal/modules/dnd/normalize/scenedescriptions/normalizer_test.go @@ -11,7 +11,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" ) func TestNormalizeTrimsOrdersDeduplicatesAndOwnsOutput(t *testing.T) { @@ -35,7 +34,7 @@ func TestNormalizeTrimsOrdersDeduplicatesAndOwnsOutput(t *testing.T) { if !reflect.DeepEqual(result.Value.Scenes, want) { t.Fatalf("scenes = %#v, want %#v", result.Value.Scenes, want) } - if len(result.Warnings) != 0 || len(result.Diagnostics) != 3 || result.Diagnostics[0].ReasonCode != ReasonCodeProseNormalized || result.Diagnostics[0].OccurrenceCount != 4 || result.Diagnostics[1].ReasonCode != ReasonCodeOrderNormalized || result.Diagnostics[1].OccurrenceCount != 2 || result.Diagnostics[2].ReasonCode != ReasonCodeDuplicateCollapsed || result.Diagnostics[2].OccurrenceCount != 1 { + if len(result.Diagnostics) != 3 || result.Diagnostics[0].ReasonCode != ReasonCodeProseNormalized || result.Diagnostics[0].OccurrenceCount != 4 || result.Diagnostics[1].ReasonCode != ReasonCodeOrderNormalized || result.Diagnostics[1].OccurrenceCount != 2 || result.Diagnostics[2].ReasonCode != ReasonCodeDuplicateCollapsed || result.Diagnostics[2].OccurrenceCount != 1 { t.Fatalf("diagnostics = %#v, want grouped prose, order, and duplicate observations", result.Diagnostics) } if !reflect.DeepEqual(input, before) { @@ -55,7 +54,7 @@ func TestNormalizeTrimsOrdersDeduplicatesAndOwnsOutput(t *testing.T) { } func TestNormalizeLimitsCombinedSceneMutationWarnings(t *testing.T) { - count := diagnostics.MaxWarnings - 8 + count := contracts.MaxDiagnosticSamples + 1 doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)} input := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, count)} for index := range input.Scenes { @@ -70,7 +69,7 @@ func TestNormalizeLimitsCombinedSceneMutationWarnings(t *testing.T) { if len(result.Value.Scenes) != count || result.Value.Scenes[0].SourceRef.StartUnitID != 1 { t.Fatalf("normalized scenes = %#v, want unchanged canonical values", result.Value.Scenes) } - if len(result.Warnings) != 0 || len(result.Diagnostics) != 2 || result.Diagnostics[0].ReasonCode != ReasonCodeProseNormalized || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionObservation || result.Diagnostics[0].OccurrenceCount != count || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples || result.Diagnostics[1].ReasonCode != ReasonCodeOrderNormalized || result.Diagnostics[1].OccurrenceCount != count { + if len(result.Diagnostics) != 2 || result.Diagnostics[0].ReasonCode != ReasonCodeProseNormalized || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionObservation || result.Diagnostics[0].OccurrenceCount != count || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples || result.Diagnostics[1].ReasonCode != ReasonCodeOrderNormalized || result.Diagnostics[1].OccurrenceCount != count { t.Fatalf("diagnostics = %#v", result.Diagnostics) } } diff --git a/internal/modules/dnd/normalize/spells/normalizer.go b/internal/modules/dnd/normalize/spells/normalizer.go index dd775517..bc08debf 100644 --- a/internal/modules/dnd/normalize/spells/normalizer.go +++ b/internal/modules/dnd/normalize/spells/normalizer.go @@ -110,8 +110,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Diagnostics: diagnosticGroups}, nil } -func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []contracts.Warning) { - var warnings []contracts.Warning +func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []diagnostics.Finding) { + var warnings []diagnostics.Finding if input.SpellCasts == nil { return dnd.SpellList{}, nil } @@ -121,7 +121,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal cast := cloneSpellCast(inputCast) if canonicalName, ok := catalog.Lookup(inputCast.Spell); ok { if inputCast.Spell != canonicalName { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSpellNameCanonicalized, Message: fmt.Sprintf("input index %d: spell name canonicalized from %q to %q", @@ -130,7 +130,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal } cast.Spell = canonicalName } else { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSpellNameUnresolved, Message: fmt.Sprintf("input index %d: spell name %q could not be resolved in the effective catalog", @@ -141,7 +141,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(order, inputCast.SourceRefs) cast.SourceRefs = canonicalRefs if orderChanged || duplicateCount > 0 { - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: spellCastScope(index), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d, order changed %t, duplicates removed %d)", @@ -179,7 +179,7 @@ type duplicateGroup struct { removed []int } -func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) { +func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []diagnostics.Finding) { if len(input.SpellCasts) == 0 { return input, nil } @@ -221,7 +221,7 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.Docum } } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for _, group := range groups { if len(group.removed) == 0 { continue @@ -264,7 +264,7 @@ func writeKeyInt(builder *strings.Builder, value int) { builder.WriteByte(';') } -func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { +func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding { const maxDisplayedIndices = 20 displayed := removed if len(displayed) > maxDisplayedIndices { @@ -279,7 +279,7 @@ func duplicateWarning(retainedIndex int, removed []int) contracts.Warning { if omitted := len(removed) - len(displayed); omitted > 0 { message += fmt.Sprintf("; %d additional removed input indices omitted", omitted) } - return contracts.Warning{ + return diagnostics.Finding{ Scope: spellCastScope(retainedIndex), ReasonCode: ReasonCodeDuplicateSpellCastCollapsed, Message: message, diff --git a/internal/modules/dnd/normalize/spells/normalizer_test.go b/internal/modules/dnd/normalize/spells/normalizer_test.go index b7a98d80..e60c3396 100644 --- a/internal/modules/dnd/normalize/spells/normalizer_test.go +++ b/internal/modules/dnd/normalize/spells/normalizer_test.go @@ -12,7 +12,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" - "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics" spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" ) @@ -151,13 +150,13 @@ func TestNormalizeCanonicalizesNamesAndReportsUnresolvedNames(t *testing.T) { } canonicalized := diagnosticByReason(result.Diagnostics, ReasonCodeSpellNameCanonicalized) unresolved := diagnosticByReason(result.Diagnostics, ReasonCodeSpellNameUnresolved) - if len(result.Warnings) != 0 || canonicalized == nil || canonicalized.Disposition != contracts.DiagnosticDispositionObservation || canonicalized.OccurrenceCount != 3 || unresolved == nil || unresolved.Disposition != contracts.DiagnosticDispositionAdvisory || unresolved.OccurrenceCount != 1 || unresolved.Samples[0].Scope != "spell_casts[3]" || strings.Contains(unresolved.Samples[0].Message, "Mystery\nSpell") || !strings.Contains(unresolved.Samples[0].Message, `Mystery\nSpell\tName`) { + if canonicalized == nil || canonicalized.Disposition != contracts.DiagnosticDispositionObservation || canonicalized.OccurrenceCount != 3 || unresolved == nil || unresolved.Disposition != contracts.DiagnosticDispositionAdvisory || unresolved.OccurrenceCount != 1 || unresolved.Samples[0].Scope != "spell_casts[3]" || strings.Contains(unresolved.Samples[0].Message, "Mystery\nSpell") || !strings.Contains(unresolved.Samples[0].Message, `Mystery\nSpell\tName`) { t.Fatalf("diagnostics = %#v, want grouped canonicalization and unresolved-quality diagnostics", result.Diagnostics) } } func TestNormalizeLimitsWarningsWithoutChangingSpellValues(t *testing.T) { - input := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, diagnostics.MaxWarnings+1)} + input := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, contracts.MaxDiagnosticSamples+1)} for index := range input.SpellCasts { input.SpellCasts[index].Spell = fmt.Sprintf("Unknown Spell %d", index) } @@ -168,7 +167,7 @@ func TestNormalizeLimitsWarningsWithoutChangingSpellValues(t *testing.T) { if !reflect.DeepEqual(result.Value, input) { t.Fatalf("normalized value = %#v, want unresolved spell values preserved", result.Value) } - if len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCodeSpellNameUnresolved || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionAdvisory || result.Diagnostics[0].OccurrenceCount != len(input.SpellCasts) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCodeSpellNameUnresolved || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionAdvisory || result.Diagnostics[0].OccurrenceCount != len(input.SpellCasts) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("diagnostics = %#v", result.Diagnostics) } } @@ -350,7 +349,7 @@ func TestNormalizeCollapsesDuplicateGroupsAfterCanonicalization(t *testing.T) { canonicalized := diagnosticByReason(result.Diagnostics, ReasonCodeSpellNameCanonicalized) refsNormalized := diagnosticByReason(result.Diagnostics, ReasonCodeSourceReferencesNormalized) collapsed := diagnosticByReason(result.Diagnostics, ReasonCodeDuplicateSpellCastCollapsed) - if len(result.Warnings) != 0 || canonicalized == nil || canonicalized.OccurrenceCount != 3 || refsNormalized == nil || refsNormalized.OccurrenceCount != 1 || collapsed == nil || collapsed.OccurrenceCount != 2 || !strings.Contains(collapsed.Samples[0].Message, "retained input index 0") { + if canonicalized == nil || canonicalized.OccurrenceCount != 3 || refsNormalized == nil || refsNormalized.OccurrenceCount != 1 || collapsed == nil || collapsed.OccurrenceCount != 2 || !strings.Contains(collapsed.Samples[0].Message, "retained input index 0") { t.Fatalf("diagnostics = %#v, want grouped normalization observations", result.Diagnostics) } } @@ -380,10 +379,8 @@ func TestNormalizeKeepsDistinctAndIneligibleCastsSeparate(t *testing.T) { if len(result.Value.SpellCasts) != 2 { t.Fatalf("normalized casts = %#v, want both casts retained", result.Value.SpellCasts) } - for _, warning := range result.Warnings { - if warning.ReasonCode == ReasonCodeDuplicateSpellCastCollapsed { - t.Fatalf("warnings = %#v, want no duplicate collapse", result.Warnings) - } + if diagnosticByReason(result.Diagnostics, ReasonCodeDuplicateSpellCastCollapsed) != nil { + t.Fatalf("diagnostics = %#v, want no duplicate collapse", result.Diagnostics) } }) } @@ -404,10 +401,8 @@ func TestNormalizeDoesNotCollapseAdjacentOrOverlappingEvidence(t *testing.T) { if len(result.Value.SpellCasts) != len(input.SpellCasts) { t.Fatalf("normalized casts = %#v, want adjacent and overlapping evidence retained", result.Value.SpellCasts) } - for _, warning := range result.Warnings { - if warning.ReasonCode == ReasonCodeDuplicateSpellCastCollapsed { - t.Fatalf("warnings = %#v, want no duplicate collapse", result.Warnings) - } + if diagnosticByReason(result.Diagnostics, ReasonCodeDuplicateSpellCastCollapsed) != nil { + t.Fatalf("diagnostics = %#v, want no duplicate collapse", result.Diagnostics) } } @@ -448,8 +443,8 @@ func TestNormalizeIsIdempotentForAlreadyNormalizedInput(t *testing.T) { if err != nil { t.Fatalf("second Normalize() error = %v, want nil", err) } - if !reflect.DeepEqual(second.Value, first.Value) || len(first.Warnings) != 0 || len(second.Warnings) != 0 { - t.Fatalf("first = %#v/%#v, second = %#v/%#v, want identical artifacts without mutation warnings", first.Value, first.Warnings, second.Value, second.Warnings) + if !reflect.DeepEqual(second.Value, first.Value) { + t.Fatalf("first = %#v, second = %#v, want identical artifacts", first.Value, second.Value) } } diff --git a/internal/modules/dnd/shared/diagnostics/diagnostics.go b/internal/modules/dnd/shared/diagnostics/diagnostics.go index f5f089b0..362bd583 100644 --- a/internal/modules/dnd/shared/diagnostics/diagnostics.go +++ b/internal/modules/dnd/shared/diagnostics/diagnostics.go @@ -1,5 +1,5 @@ // Package diagnostics provides bounded, safe text for deterministic D&D -// decisions and warnings. +// decisions and findings. package diagnostics import ( @@ -13,15 +13,22 @@ import ( const ( MaxIssues = 20 - MaxWarnings = 20 MaxDisplayedRunes = 128 MaxMessageBytes = 4096 ) +// Finding is a local, ungrouped D&D diagnostic fact. Producers convert these +// to bounded framework diagnostics at their result boundary. +type Finding struct { + Scope string + ReasonCode string + Message string +} + // DataQualityResult converts accepted source-quality findings into bounded, // locally grouped advisories. These findings do not indicate process // degradation. -func DataQualityResult(findings []contracts.Warning) (contracts.ValidationResult, error) { +func DataQualityResult(findings []Finding) (contracts.ValidationResult, error) { diagnostics, err := Collect(findings, contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality) if err != nil { return contracts.ValidationResult{}, err @@ -31,7 +38,7 @@ func DataQualityResult(findings []contracts.Warning) (contracts.ValidationResult // Collect converts individual deterministic findings into bounded, // occurrence-grouped producer diagnostics with one consistent classification. -func Collect(findings []contracts.Warning, disposition contracts.DiagnosticDisposition, category contracts.DiagnosticCategory) ([]contracts.ProducerDiagnostic, error) { +func Collect(findings []Finding, disposition contracts.DiagnosticDisposition, category contracts.DiagnosticCategory) ([]contracts.ProducerDiagnostic, error) { collector := frameworkdiagnostics.NewCollector() for _, finding := range findings { if err := collector.Add(contracts.ProducerDiagnostic{ @@ -49,13 +56,13 @@ func Collect(findings []contracts.Warning, disposition contracts.DiagnosticDispo // NormalizationDiagnostics classifies deterministic normalization findings as // observations, except for explicitly identified unresolved-quality findings. -func NormalizationDiagnostics(findings []contracts.Warning, advisoryReasonCodes ...string) ([]contracts.ProducerDiagnostic, error) { +func NormalizationDiagnostics(findings []Finding, advisoryReasonCodes ...string) ([]contracts.ProducerDiagnostic, error) { advisoryReasons := make(map[string]struct{}, len(advisoryReasonCodes)) for _, reasonCode := range advisoryReasonCodes { advisoryReasons[reasonCode] = struct{}{} } - observations := make([]contracts.Warning, 0, len(findings)) - advisories := make([]contracts.Warning, 0) + observations := make([]Finding, 0, len(findings)) + advisories := make([]Finding, 0) for _, finding := range findings { if _, advisory := advisoryReasons[finding.ReasonCode]; advisory { advisories = append(advisories, finding) @@ -97,28 +104,6 @@ func Aggregate(prefix string, issues []string) string { return aggregateMessage(prefix, displayed, len(issues)-len(displayed)) } -// LimitWarnings returns at most MaxWarnings warnings, reserving the final -// position for a deterministic omission summary when truncation is required. -func LimitWarnings(warnings []contracts.Warning, scope, reasonCode string) []contracts.Warning { - if warnings == nil { - return nil - } - if len(warnings) <= MaxWarnings { - bounded := make([]contracts.Warning, len(warnings)) - copy(bounded, warnings) - return bounded - } - displayed := MaxWarnings - 1 - bounded := make([]contracts.Warning, displayed, MaxWarnings) - copy(bounded, warnings[:displayed]) - bounded = append(bounded, contracts.Warning{ - Scope: scope, - ReasonCode: reasonCode, - Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed), - }) - return bounded -} - func aggregateMessage(prefix string, issues []string, omitted int) string { message := prefix + ": " + strings.Join(issues, ", ") if omitted > 0 { diff --git a/internal/modules/dnd/shared/diagnostics/diagnostics_test.go b/internal/modules/dnd/shared/diagnostics/diagnostics_test.go index 42c800e5..2c863c47 100644 --- a/internal/modules/dnd/shared/diagnostics/diagnostics_test.go +++ b/internal/modules/dnd/shared/diagnostics/diagnostics_test.go @@ -2,7 +2,6 @@ package diagnostics import ( "fmt" - "reflect" "strings" "testing" "unicode/utf8" @@ -30,30 +29,8 @@ func TestAggregateEnforcesByteBudgetAndReportsOmissions(t *testing.T) { } } -func TestLimitWarningsBoundsOutputAndReportsOmissions(t *testing.T) { - warnings := make([]contracts.Warning, MaxWarnings+3) - for index := range warnings { - warnings[index] = contracts.Warning{ReasonCode: fmt.Sprintf("warning-%d", index)} - } - before := append([]contracts.Warning(nil), warnings...) - - got := LimitWarnings(warnings, "records", "warnings_omitted") - if len(got) != MaxWarnings { - t.Fatalf("LimitWarnings() count = %d, want %d", len(got), MaxWarnings) - } - summary := got[len(got)-1] - wantOmitted := len(warnings) - (MaxWarnings - 1) - if summary.Scope != "records" || summary.ReasonCode != "warnings_omitted" || - summary.Message != fmt.Sprintf("%d additional warning(s) omitted", wantOmitted) { - t.Fatalf("summary = %#v", summary) - } - if !reflect.DeepEqual(warnings, before) { - t.Fatal("LimitWarnings() mutated its input") - } -} - func TestDataQualityResultGroupsFindingsWithoutWarnings(t *testing.T) { - result, err := DataQualityResult([]contracts.Warning{ + result, err := DataQualityResult([]Finding{ {Scope: "records[0]", ReasonCode: "not_near_source", Message: "first"}, {Scope: "records[0]", ReasonCode: "not_near_source", Message: "first"}, {Scope: "records[1]", ReasonCode: "not_near_source", Message: "second"}, @@ -61,7 +38,7 @@ func TestDataQualityResultGroupsFindingsWithoutWarnings(t *testing.T) { if err != nil { t.Fatal(err) } - if !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 { + if !result.Approved || len(result.Diagnostics) != 1 { t.Fatalf("result = %#v", result) } diagnostic := result.Diagnostics[0] @@ -71,9 +48,9 @@ func TestDataQualityResultGroupsFindingsWithoutWarnings(t *testing.T) { } func TestCollectGroupsNormalizationFindingsWithBoundedSamples(t *testing.T) { - findings := make([]contracts.Warning, 5) + findings := make([]Finding, 5) for index := range findings { - findings[index] = contracts.Warning{ + findings[index] = Finding{ Scope: fmt.Sprintf("records[%d]", index), ReasonCode: "record_normalized", Message: fmt.Sprintf("record %d normalized", index), @@ -94,7 +71,7 @@ func TestCollectGroupsNormalizationFindingsWithBoundedSamples(t *testing.T) { } func TestNormalizationDiagnosticsCombinesQualityAndCleanupWithoutWarnings(t *testing.T) { - groups, err := NormalizationDiagnostics([]contracts.Warning{ + groups, err := NormalizationDiagnostics([]Finding{ {Scope: "spell_casts[0]", ReasonCode: "spell_name_canonicalized", Message: "canonicalized spell name"}, {Scope: "spell_casts[1]", ReasonCode: "spell_name_unresolved", Message: "spell was not in the catalog"}, }, "spell_name_unresolved") diff --git a/internal/modules/dnd/validate/combatturns/invariants/validator_test.go b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go index 912b4036..bb1f820c 100644 --- a/internal/modules/dnd/validate/combatturns/invariants/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/invariants/validator_test.go @@ -15,7 +15,7 @@ import ( func TestValidatorApprovesNormalizedCombatTurns(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: normalizedList()}) - if err != nil || !result.Approved || len(result.Warnings) != 0 { + if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err) } } diff --git a/internal/modules/dnd/validate/combatturns/shape/validator_test.go b/internal/modules/dnd/validate/combatturns/shape/validator_test.go index 1e24dc83..c60b823b 100644 --- a/internal/modules/dnd/validate/combatturns/shape/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/shape/validator_test.go @@ -14,7 +14,7 @@ import ( func TestValidatorApprovesWellFormedCombatTurnList(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: validCombatTurnList()}) - if err != nil || !result.Approved || len(result.Warnings) != 0 { + if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err) } } diff --git a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go index 6b7076d1..ed587a59 100644 --- a/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_refs/validator_test.go @@ -85,7 +85,7 @@ func TestValidatorEnforcesCurrentChunkEvidenceDuringExtraction(t *testing.T) { func TestValidatorDefersMalformedShape(t *testing.T) { value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 { + if err != nil || !result.Approved { t.Fatalf("shape deferral = %#v, %v; want approval without source warning", result, err) } } diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go index 3e56f3fb..90b06fa7 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator.go @@ -50,13 +50,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[turnIndex] = citedText } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for turnIndex, turn := range req.Value.CombatTurns { citedText := citedTexts[turnIndex] if actorAppearsInCitedText(citedText, turn.Actor) { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: fmt.Sprintf("combat_turns[%d]", turnIndex), ReasonCode: ReasonCode, Message: diagnostics.Aggregate("combat turn not near source", []string{ diff --git a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go index f6562121..835df64a 100644 --- a/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/combatturns/source_relatedness/validator_test.go @@ -36,7 +36,7 @@ func TestValidatorWarnsOncePerTurnForUnrelatedActor(t *testing.T) { SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}, }}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 { t.Fatalf("Validate() = %#v, %v; want one advisory for the turn", result, err) } diagnostic := result.Diagnostics[0] @@ -62,7 +62,7 @@ func TestValidatorLimitsUnrelatedActorWarnings(t *testing.T) { if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) } - if len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(turns) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples || result.Diagnostics[0].OmittedSampleCount != len(turns)-contracts.MaxDiagnosticSamples { + if len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(turns) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples || result.Diagnostics[0].OmittedSampleCount != len(turns)-contracts.MaxDiagnosticSamples { t.Fatalf("diagnostics = %#v", result.Diagnostics) } } diff --git a/internal/modules/dnd/validate/correction_contract_test.go b/internal/modules/dnd/validate/correction_contract_test.go index 4054a51f..24dfb752 100644 --- a/internal/modules/dnd/validate/correction_contract_test.go +++ b/internal/modules/dnd/validate/correction_contract_test.go @@ -35,7 +35,6 @@ func TestRepresentativeProductionValidatorResultsSatisfyCorrectionContract(t *te name string validate func() (contracts.ValidationResult, error) wantApproved bool - wantWarningCount int wantDiagnosticCount int wantDisposition contracts.DiagnosticDisposition }{ @@ -123,9 +122,6 @@ func TestRepresentativeProductionValidatorResultsSatisfyCorrectionContract(t *te if result.Approved != test.wantApproved { t.Fatalf("Validate() approved = %t, want %t: %#v", result.Approved, test.wantApproved, result) } - if len(result.Warnings) != test.wantWarningCount { - t.Fatalf("Validate() warnings = %d, want %d: %#v", len(result.Warnings), test.wantWarningCount, result) - } if len(result.Diagnostics) != test.wantDiagnosticCount { t.Fatalf("Validate() diagnostics = %d, want %d: %#v", len(result.Diagnostics), test.wantDiagnosticCount, result) } diff --git a/internal/modules/dnd/validate/enemyevents/shape/validator_test.go b/internal/modules/dnd/validate/enemyevents/shape/validator_test.go index 3b757806..44ee268b 100644 --- a/internal/modules/dnd/validate/enemyevents/shape/validator_test.go +++ b/internal/modules/dnd/validate/enemyevents/shape/validator_test.go @@ -15,7 +15,7 @@ import ( func TestValidatorAcceptsEmptyAndWellFormedEventLists(t *testing.T) { for _, value := range []dnd.EnemyEventList{{Events: []dnd.EnemyEvent{}}, validEventList()} { result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 { + if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) } } diff --git a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go index 08378e3f..793e13d7 100644 --- a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator.go @@ -42,13 +42,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq if err != nil { return contracts.ValidationResult{Approved: true}, nil } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for eventIndex, event := range req.Value.Events { citedText, err := resolver.CitedText(event.SourceRefs) if err != nil || shared.ContainsTokenSequence(citedText, event.Name) { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: fmt.Sprintf("events[%d]", eventIndex), ReasonCode: ReasonCode, Message: diagnostics.Aggregate("enemy event subject not near source", []string{ diff --git a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go index a17c7a3b..d3570cd1 100644 --- a/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/enemyevents/source_relatedness/validator_test.go @@ -14,7 +14,7 @@ func TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) { value := validEventList() references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"npc_registry": {Items: []contracts.ReferenceItem{{Content: []byte("Ashfang")}}}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("The party waits."), References: references, Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionAdvisory { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionAdvisory { t.Fatalf("Validate() = %#v, %v", result, err) } } @@ -39,7 +39,7 @@ func TestValidatorDefersMalformedValuesAndBoundsWarnings(t *testing.T) { value.Events[index] = dnd.EnemyEvent{Name: "Missing", Kind: dnd.EnemyEventKindEngaged, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} } result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(value.Events) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(value.Events) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("bounded advisories = %#v, %v", result, err) } registry := pipeline.NewValidatorRegistry() diff --git a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go index b097cbb3..254adb32 100644 --- a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator.go @@ -45,13 +45,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq return contracts.ValidationResult{Approved: true}, nil } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, occurrence := range req.Value.Occurrences { citedText, err := resolver.CitedText(occurrence.SourceRefs) if err != nil || shared.ContainsTokenSequence(citedText, occurrence.Name) { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)), diff --git a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go index be856778..4e5ffd90 100644 --- a/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/itemoccurrences/source_relatedness/validator_test.go @@ -25,7 +25,7 @@ func TestValidatorMatchesTokenSequencesAcrossRanges(t *testing.T) { "glossary": {Items: []contracts.ReferenceItem{{Content: []byte("missing-item")}}}, }} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemOccurrenceList]{Source: doc, References: references, Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 { t.Fatalf("Validate() = %#v, %v", result, err) } if diagnostic := result.Diagnostics[0]; diagnostic.Samples[0].Scope != "occurrences[1]" || diagnostic.ReasonCode != ReasonCode || !strings.Contains(diagnostic.Samples[0].Message, "missing-item") { @@ -56,7 +56,7 @@ func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) { Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "nothing useful"}}}, Value: dnd.ItemOccurrenceList{Occurrences: occurrences}, }) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != count || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != count || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("Validate() = %#v, %v", result, err) } if got := New(Options{}).CheckpointFingerprints(); !reflect.DeepEqual(got, []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}) { diff --git a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go index 0a2ec6ef..389d32b1 100644 --- a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator.go @@ -50,12 +50,12 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[itemIndex] = citedText } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for itemIndex, item := range req.Value.Items { if shared.ContainsTokenSequence(citedTexts[itemIndex], item.Name) { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: fmt.Sprintf("items[%d]", itemIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("Item %s was not found in cited source text", diagnostics.Quote(item.Name)), }) diff --git a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go index d816a266..b7aff393 100644 --- a/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/itemregistry/source_relatedness/validator_test.go @@ -21,7 +21,7 @@ func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) { value.Items[0].Name = "Glossary Relic" before := value result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, References: contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Glossary Relic")}}}}}, Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !reflect.DeepEqual(value, before) { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !reflect.DeepEqual(value, before) { t.Fatalf("reference-only match = %#v, %v; want advisory warning", result, err) } } @@ -32,7 +32,7 @@ func TestValidatorBoundsWarningsAndRegisters(t *testing.T) { items[index] = dnd.Item{ID: "item", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} } result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "Nothing here."}}}, Value: dnd.ItemRegistry{Items: items}}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(items) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(items) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("bounded advisories = %#v, %v", result, err) } registry := pipeline.NewValidatorRegistry() diff --git a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go index 240d5a0c..2f447b5c 100644 --- a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator.go @@ -50,12 +50,12 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[index] = citedText } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, occurrence := range req.Value.Occurrences { if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) { continue } - warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("Location occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name))}) + warnings = append(warnings, diagnostics.Finding{Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("Location occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name))}) } return diagnostics.DataQualityResult(warnings) } diff --git a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go index a2c478a2..26a933ea 100644 --- a/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/locationoccurrences/source_relatedness/validator_test.go @@ -23,7 +23,7 @@ func TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) { before := cloneList(value) references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Glossary Keep")}}}}} result, err = New(Options{}).Validate(context.Background(), request(doc, value, references)) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !strings.Contains(result.Diagnostics[0].Samples[0].Message, "Glossary Keep") || !reflect.DeepEqual(value, before) { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !strings.Contains(result.Diagnostics[0].Samples[0].Message, "Glossary Keep") || !reflect.DeepEqual(value, before) { t.Fatalf("reference-only match = %#v, %v", result, err) } } @@ -41,7 +41,7 @@ func TestValidatorDefersUnreadableEvidenceAndBoundsWarnings(t *testing.T) { occurrences[index] = occurrenceList("Missing").Occurrences[0] } result, err = New(Options{}).Validate(context.Background(), request(document(), dnd.LocationOccurrenceList{Occurrences: occurrences}, contracts.ReferenceSet{})) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(occurrences) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(occurrences) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("bounded advisories = %#v, %v", result, err) } } diff --git a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go index d6062f2e..41d8fe52 100644 --- a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator.go @@ -50,12 +50,12 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[locationIndex] = citedText } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for locationIndex, location := range req.Value.Locations { if shared.ContainsTokenSequence(citedTexts[locationIndex], location.Name) { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: fmt.Sprintf("locations[%d]", locationIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("Location %s was not found in cited source text", diagnostics.Quote(location.Name)), }) diff --git a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go index c76c5bdb..896d8959 100644 --- a/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/locationregistry/source_relatedness/validator_test.go @@ -23,7 +23,7 @@ func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) { before := value references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Glossary Keep")}}}}} result, err = New(Options{}).Validate(context.Background(), request(doc, value, references)) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !strings.Contains(result.Diagnostics[0].Samples[0].Message, "Glossary Keep") || !reflect.DeepEqual(value, before) { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].ReasonCode != ReasonCode || !strings.Contains(result.Diagnostics[0].Samples[0].Message, "Glossary Keep") || !reflect.DeepEqual(value, before) { t.Fatalf("reference-only match = %#v, %v; want advisory warning", result, err) } } @@ -60,7 +60,7 @@ func TestValidatorBoundsWarnings(t *testing.T) { locations[index] = dnd.Location{ID: "candidate", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}} } result, err := New(Options{}).Validate(context.Background(), request(document(), dnd.LocationRegistry{Locations: locations}, contracts.ReferenceSet{})) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(locations) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(locations) || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("bounded advisories = %#v, %v", result, err) } } diff --git a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go index af23bde8..964db278 100644 --- a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator.go @@ -50,12 +50,12 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[index] = citedText } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, occurrence := range req.Value.Occurrences { if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: fmt.Sprintf("occurrences[%d]", index), ReasonCode: ReasonCode, Message: fmt.Sprintf("NPC occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)), diff --git a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go index 8851adab..a3f1feeb 100644 --- a/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/npcoccurrences/source_relatedness/validator_test.go @@ -20,7 +20,7 @@ func TestValidatorUsesOnlyCurrentTranscriptAndWarnsOncePerOccurrence(t *testing. doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "O’Rin Thorn speaks."}, {ID: 2, Text: "The party waits."}}} references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Missing NPC")}}}}} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCOccurrenceList]{Source: doc, References: references, Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 { t.Fatalf("Validate() = %#v, %v", result, err) } if diagnostic := result.Diagnostics[0]; diagnostic.Samples[0].Scope != "occurrences[1]" || diagnostic.ReasonCode != ReasonCode || !strings.Contains(diagnostic.Samples[0].Message, `Missing\nNPC`) { @@ -68,7 +68,7 @@ func TestValidatorBoundsWarnings(t *testing.T) { if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v", result, err) } - if len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != count || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { + if len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != count || len(result.Diagnostics[0].Samples) != contracts.MaxDiagnosticSamples { t.Fatalf("diagnostics = %#v", result.Diagnostics) } } diff --git a/internal/modules/dnd/validate/npcregistry/source_refs/validator_test.go b/internal/modules/dnd/validate/npcregistry/source_refs/validator_test.go index 77e81774..c474d314 100644 --- a/internal/modules/dnd/validate/npcregistry/source_refs/validator_test.go +++ b/internal/modules/dnd/validate/npcregistry/source_refs/validator_test.go @@ -15,7 +15,7 @@ import ( func TestValidatorApprovesValidSourceReferences(t *testing.T) { result, err := New(Options{}).Validate(context.Background(), requestWithValue(validDocument(), validNPCRegistry())) - if err != nil || !result.Approved || len(result.Warnings) != 0 { + if err != nil || !result.Approved { t.Fatalf("Validate() = %#v, %v; want approval", result, err) } } diff --git a/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go b/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go index 8ef01264..b422856c 100644 --- a/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/npcregistry/source_relatedness/validator.go @@ -49,12 +49,12 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[npcIndex] = citedText } - var warnings []contracts.Warning + var warnings []diagnostics.Finding for npcIndex, npc := range req.Value.NPCs { if npcAppearsInCitedText(citedTexts[npcIndex], npc) { continue } - warnings = append(warnings, contracts.Warning{ + warnings = append(warnings, diagnostics.Finding{ Scope: fmt.Sprintf("npcs[%d]", npcIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("NPC %s was not found in cited source text", diagnostics.Quote(npc.Name)), diff --git a/internal/modules/dnd/validate/npcregistry/source_relatedness/validator_test.go b/internal/modules/dnd/validate/npcregistry/source_relatedness/validator_test.go index aae0bd7a..59fb34f7 100644 --- a/internal/modules/dnd/validate/npcregistry/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/npcregistry/source_relatedness/validator_test.go @@ -36,7 +36,7 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) { {ID: "one", Name: "Missing\nName", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}, {SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}, }} result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCRegistry]{Source: relatednessDocument(), Value: value}) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 { t.Fatalf("Validate() = %#v, %v; want one advisory", result, err) } diagnostic := result.Diagnostics[0] diff --git a/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go b/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go index 5bd9ca40..20159235 100644 --- a/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator.go @@ -51,7 +51,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq if err != nil { return contracts.ValidationResult{Approved: true}, nil } - warnings := make([]contracts.Warning, 0) + warnings := make([]diagnostics.Finding, 0) for index, scene := range req.Value.Scenes { citedText, err := resolver.CitedText([]source.SourceRef{scene.SourceRef}) if err != nil { @@ -98,8 +98,8 @@ func significant(token string) bool { return !ignored } -func warning(index int, field string) contracts.Warning { - return contracts.Warning{ +func warning(index int, field string) diagnostics.Finding { + return diagnostics.Finding{ Scope: fmt.Sprintf("scenes[%d].%s", index, field), ReasonCode: ReasonCode, Message: "scene description " + field + " has no significant token in cited source text", diff --git a/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator_test.go b/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator_test.go index 505beced..faf126fd 100644 --- a/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/scenedescriptions/source_relatedness/validator_test.go @@ -24,7 +24,7 @@ func TestValidatorWarnsIndependentlyForUngroundedTitleAndSummary(t *testing.T) { t.Fatalf("Validate() = %#v, %v", result, err) } wantScopes := []string{"scenes[1].title", "scenes[1].summary", "scenes[2].title", "scenes[2].summary"} - if len(result.Warnings) != 0 || len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(wantScopes) { + if len(result.Diagnostics) != 1 || result.Diagnostics[0].OccurrenceCount != len(wantScopes) { t.Fatalf("diagnostics = %#v, want %d occurrences", result.Diagnostics, len(wantScopes)) } for index, scope := range wantScopes[:contracts.MaxDiagnosticSamples] { diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator.go b/internal/modules/dnd/validate/spells/source_relatedness/validator.go index 1533a15b..9e35b0d4 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator.go @@ -49,10 +49,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } citedTexts[spellIndex] = citedText } - var warnings []contracts.Warning + var warnings []diagnostics.Finding for spellIndex, spell := range req.Value.SpellCasts { if !spellAppearsInCitedText(citedTexts[spellIndex], spell) { - warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("spell %s was not found in cited source text", diagnostics.Quote(strings.TrimSpace(spell.Spell)))}) + warnings = append(warnings, diagnostics.Finding{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: ReasonCode, Message: fmt.Sprintf("spell %s was not found in cited source text", diagnostics.Quote(strings.TrimSpace(spell.Spell)))}) } } return diagnostics.DataQualityResult(warnings) diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go index 774e1e8a..a282e3b1 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator_test.go @@ -31,7 +31,7 @@ func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) { if !result.Approved { t.Fatalf("Approved = false, want true") } - if len(result.Warnings) != 0 || len(result.Diagnostics) != 1 { + if len(result.Diagnostics) != 1 { t.Fatalf("Diagnostics = %#v, want one advisory", result.Diagnostics) } if result.Diagnostics[0].ReasonCode != ReasonCode || result.Diagnostics[0].Disposition != contracts.DiagnosticDispositionAdvisory { @@ -81,7 +81,7 @@ func TestValidatorBoundsHighCardinalitySpellFindings(t *testing.T) { Source: &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "The party waits."}}}, Value: dnd.SpellList{SpellCasts: casts}, }) - if err != nil || !result.Approved || len(result.Warnings) != 0 || len(result.Diagnostics) != 1 { + if err != nil || !result.Approved || len(result.Diagnostics) != 1 { t.Fatalf("Validate() = %#v, %v", result, err) } diagnostic := result.Diagnostics[0] diff --git a/internal/modules/generic/output/json/encoder.go b/internal/modules/generic/output/json/encoder.go index 06c079b5..7cbe84e3 100644 --- a/internal/modules/generic/output/json/encoder.go +++ b/internal/modules/generic/output/json/encoder.go @@ -259,7 +259,7 @@ type rejectedFile struct { } type warningsFile struct { - Warnings []contracts.Warning `json:"warnings"` + Groups []contracts.DiagnosticGroup `json:"groups"` } func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.OutputFile, error) { @@ -332,7 +332,7 @@ func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.Out if err != nil { return nil, err } - warningsOutput, err := jsonFile("warnings.json", warningsFile{Warnings: cloneWarnings(req.Warnings)}) + warningsOutput, err := jsonFile("warnings.json", warningsFile{Groups: warningGroups(req.Diagnostics)}) if err != nil { return nil, err } @@ -487,11 +487,14 @@ func cloneRejected(rejected []contracts.RejectedOutput) []contracts.RejectedOutp return append([]contracts.RejectedOutput(nil), rejected...) } -func cloneWarnings(warnings []contracts.Warning) []contracts.Warning { - if len(warnings) == 0 { - return []contracts.Warning{} +func warningGroups(collection contracts.DiagnosticCollection) []contracts.DiagnosticGroup { + groups := make([]contracts.DiagnosticGroup, 0) + for _, group := range collection.Groups { + if group.Disposition == contracts.DiagnosticDispositionWarning { + groups = append(groups, group) + } } - return append([]contracts.Warning(nil), warnings...) + return contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: groups}).Groups } func encoderErrorf(format string, args ...any) error { diff --git a/internal/modules/generic/output/json/encoder_test.go b/internal/modules/generic/output/json/encoder_test.go index 86a13c1a..e742ecad 100644 --- a/internal/modules/generic/output/json/encoder_test.go +++ b/internal/modules/generic/output/json/encoder_test.go @@ -146,7 +146,7 @@ func TestEncodeReturnsLogicalFilesForNormalizedOutputs(t *testing.T) { ValidatorName: "validator", }, }, - Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "check source"}}, + Diagnostics: warningDiagnostics("warning", "check source"), } result, err := New().Encode(context.Background(), req) @@ -211,7 +211,7 @@ func TestEncodeIncludesRejectedAndWarningsWhenEmpty(t *testing.T) { t.Fatalf("rejected = %#v, want empty array", got) } warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json")) - if got := warnings["warnings"].([]any); len(got) != 0 { + if got := warnings["groups"].([]any); len(got) != 0 { t.Fatalf("warnings = %#v, want empty array", got) } } @@ -665,7 +665,7 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) { Rejected: []contracts.RejectedOutput{ {Stage: "extract", LaneID: "spells", Message: "not accepted"}, }, - Warnings: []contracts.Warning{{ReasonCode: "warning", Message: "message"}}, + Diagnostics: warningDiagnostics("warning", "message"), } before := mustMarshal(t, req) @@ -681,14 +681,14 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) { req.NormalizeOutputs[0].Artifact.Content[0] = '[' req.NormalizeOutputs[0].Artifact.Metadata["name"] = "changed" req.Rejected[0].Message = "changed" - req.Warnings[0].Message = "changed" + req.Diagnostics.Groups[0].Samples[0].Message = "changed" if !stdjson.Valid(fileBytes(t, result.Files, "lanes/spells.json")) { t.Fatal("output changed after request mutation") } warnings := decodeObject(t, fileBytes(t, result.Files, "warnings.json")) - gotWarnings := warnings["warnings"].([]any) - if gotWarnings[0].(map[string]any)["message"] != "message" { + gotWarnings := warnings["groups"].([]any) + if gotWarnings[0].(map[string]any)["samples"].([]any)[0].(map[string]any)["message"] != "message" { t.Fatalf("warnings output changed after request mutation: %#v", gotWarnings) } } @@ -696,9 +696,7 @@ func TestEncodeDoesNotMutateInputs(t *testing.T) { func TestOutputFilesDoNotContainWarnings(t *testing.T) { result, err := New().Encode(context.Background(), contracts.OutputRequest{ NormalizeOutputs: []contracts.SerializedOutput{normalizeOutput("spells", `{"spell":"Shield"}`)}, - Warnings: []contracts.Warning{ - {ReasonCode: "pipeline-warning", Message: "warning"}, - }, + Diagnostics: warningDiagnostics("pipeline-warning", "warning"), }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) @@ -710,6 +708,17 @@ func TestOutputFilesDoNotContainWarnings(t *testing.T) { } } +func warningDiagnostics(reasonCode, message string) contracts.DiagnosticCollection { + return contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{{ + Disposition: contracts.DiagnosticDispositionWarning, + Category: contracts.DiagnosticCategoryDegradation, + ReasonCode: reasonCode, + Origin: contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageChunk}, + OccurrenceCount: 1, + Samples: []contracts.DiagnosticSample{{Scope: "test", Message: message}}, + }}} +} + func normalizeOutput(laneID string, content string) contracts.SerializedOutput { return contracts.SerializedOutput{ LaneID: laneID, diff --git a/internal/modules/integration/dnd_combat_runner_test.go b/internal/modules/integration/dnd_combat_runner_test.go index 2d73d333..1b9529d8 100644 --- a/internal/modules/integration/dnd_combat_runner_test.go +++ b/internal/modules/integration/dnd_combat_runner_test.go @@ -98,8 +98,8 @@ func TestProductionCombatPipelineRetriesMergesNormalizesAndWritesJSON(t *testing } } } - if !hasCombatWarning(output.Warnings, combatnormalize.ReasonCodeActorCanonicalized) { - t.Fatalf("warnings = %#v, want registry normalization warning", output.Warnings) + if !hasCombatWarning(output.Diagnostics, combatnormalize.ReasonCodeActorCanonicalized) { + t.Fatalf("diagnostics = %#v, want registry normalization advisory", output.Diagnostics) } if len(output.Manifest.References) != 3 { t.Fatalf("manifest references = %#v, want scene eligibility plus extract and normalize NPC provenance", output.Manifest.References) @@ -217,8 +217,8 @@ func TestCombatPipelinePublishesUnavailableSceneWarningForMismatchedReference(t if err != nil { t.Fatalf("Run() error = %v", err) } - if len(client.requests) != 0 || len(output.Rejected) != 0 || !hasCombatWarning(output.Warnings, "scene_classification_unavailable") { - t.Fatalf("requests/rejected/warnings = %#v / %#v / %#v, want accepted unavailable-scene skip without a combat request", client.requests, output.Rejected, output.Warnings) + if len(client.requests) != 0 || len(output.Rejected) != 0 || !hasCombatWarning(output.Diagnostics, "scene_classification_unavailable") { + t.Fatalf("requests/rejected/diagnostics = %#v / %#v / %#v, want accepted unavailable-scene skip without a combat request", client.requests, output.Rejected, output.Diagnostics) } if len(output.NormalizeOutputs) != 1 { t.Fatalf("normalized outputs = %#v, want accepted empty combat lane", output.NormalizeOutputs) @@ -402,9 +402,9 @@ func combatTestTurnResponse(actor, turnKind string, unit int) string { return fmt.Sprintf(`{"combat_turns":[{"actor":%q,"turn_kind":%q,"source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, actor, turnKind, unit, unit) } -func hasCombatWarning(warnings []contracts.Warning, reason string) bool { - for _, warning := range warnings { - if warning.ReasonCode == reason { +func hasCombatWarning(diagnostics contracts.DiagnosticCollection, reason string) bool { + for _, group := range diagnostics.Groups { + if group.ReasonCode == reason { return true } } diff --git a/internal/modules/integration/dnd_npc_grounded_test.go b/internal/modules/integration/dnd_npc_grounded_test.go index 15fb17b9..345fb71c 100644 --- a/internal/modules/integration/dnd_npc_grounded_test.go +++ b/internal/modules/integration/dnd_npc_grounded_test.go @@ -325,8 +325,8 @@ func TestGroundedPipelineSkipsCombatForExactNarrativeScene(t *testing.T) { if combatCompletionCount(client.requestsSnapshot()) != 0 { t.Fatalf("combat completion requests = %#v, want none for an exact narrative scene", client.requestsSnapshot()) } - if len(output.Rejected) != 0 || hasCombatWarning(output.Warnings, "scene_classification_unavailable") { - t.Fatalf("narrative output rejected/warnings = %#v / %#v, want accepted skip without unavailable warning", output.Rejected, output.Warnings) + if len(output.Rejected) != 0 || hasCombatWarning(output.Diagnostics, "scene_classification_unavailable") { + t.Fatalf("narrative output rejected/diagnostics = %#v / %#v, want accepted skip without unavailable advisory", output.Rejected, output.Diagnostics) } combatValue := normalizedCombatOutput(t, output) if combatValue.CombatTurns == nil || len(combatValue.CombatTurns) != 0 { diff --git a/internal/modules/integration/dnd_npc_registry_runner_test.go b/internal/modules/integration/dnd_npc_registry_runner_test.go index e2796dce..1b42a128 100644 --- a/internal/modules/integration/dnd_npc_registry_runner_test.go +++ b/internal/modules/integration/dnd_npc_registry_runner_test.go @@ -86,8 +86,8 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T) } } } - if !hasNPCWarning(output.Warnings, "duplicate_npc_collapsed") { - t.Fatalf("warnings = %#v, want name-only consolidation warning", output.Warnings) + if !hasNPCWarning(output.Diagnostics, "duplicate_npc_collapsed") { + t.Fatalf("diagnostics = %#v, want name-only consolidation advisory", output.Diagnostics) } if output.Manifest.ValidationStatus != "approved" || len(output.Manifest.ArtifactLanes) != 1 { t.Fatalf("manifest = %#v, want approved NPC lane", output.Manifest) @@ -213,8 +213,8 @@ func TestProductionNPCNormalizationRetryUsesFinalSafeProposal(t *testing.T) { if client.requestCount(npcnormalize.PromptID) != test.wantCalls || len(output.NormalizeOutputs) != 1 { t.Fatalf("normalization calls = %d output = %#v, want %d accepted result", client.requestCount(npcnormalize.PromptID), output.NormalizeOutputs, test.wantCalls) } - if got := hasWarningReason(output.Warnings, npcnormalize.ReasonCodeNPCSemanticReconciliationExhausted); got != test.wantExhaustion { - t.Fatalf("warnings = %#v, exhaustion = %t, want %t", output.Warnings, got, test.wantExhaustion) + if got := hasWarningReason(output.Diagnostics, npcnormalize.ReasonCodeNPCSemanticReconciliationExhausted); got != test.wantExhaustion { + t.Fatalf("diagnostics = %#v, exhaustion = %t, want %t", output.Diagnostics, got, test.wantExhaustion) } }) } @@ -355,18 +355,18 @@ func readNPCFixture(t *testing.T) []byte { return raw } -func hasNPCWarning(warnings []contracts.Warning, reason string) bool { - for _, warning := range warnings { - if warning.ReasonCode == reason && strings.HasPrefix(warning.Scope, "npcs[") { +func hasNPCWarning(diagnostics contracts.DiagnosticCollection, reason string) bool { + for _, group := range diagnostics.Groups { + if group.ReasonCode == reason && len(group.Samples) > 0 && strings.HasPrefix(group.Samples[0].Scope, "npcs[") { return true } } return false } -func hasWarningReason(warnings []contracts.Warning, reason string) bool { - for _, warning := range warnings { - if warning.ReasonCode == reason { +func hasWarningReason(diagnostics contracts.DiagnosticCollection, reason string) bool { + for _, group := range diagnostics.Groups { + if group.ReasonCode == reason { return true } }