Harden diagnostic handling and warning presentation
This commit is contained in:
@@ -103,8 +103,9 @@ names, requiredness, and configured bindings are part of the
|
|||||||
Without **--json**, standard output contains the completed pipeline ID, counts
|
Without **--json**, standard output contains the completed pipeline ID, counts
|
||||||
of normalized and rejected outputs, and the output directory. A debug-enabled
|
of normalized and rejected outputs, and the output directory. A debug-enabled
|
||||||
run also prints its debug-bundle path to standard output. A successful run with
|
run also prints its debug-bundle path to standard output. A successful run with
|
||||||
actionable process warnings report their group and occurrence counts plus the
|
actionable process warnings reports their group and occurrence counts to
|
||||||
durable warning-file path to standard error. Advisory and observation findings
|
standard error. When the selected output module publishes `warnings.json`, the
|
||||||
|
summary also reports that durable file's path. Advisory and observation findings
|
||||||
do not produce a warning line. The published JSON bundle
|
do not produce a warning line. The published JSON bundle
|
||||||
is defined by the [JSON output contract](integrations/json-output.md).
|
is defined by the [JSON output contract](integrations/json-output.md).
|
||||||
|
|
||||||
|
|||||||
@@ -71,17 +71,17 @@ func TestAssembledSpellPipelineNormalizesMergedCasts(t *testing.T) {
|
|||||||
t.Fatalf("distinct cast = %#v, want separate evidence event", distinct)
|
t.Fatalf("distinct cast = %#v, want separate evidence event", distinct)
|
||||||
}
|
}
|
||||||
|
|
||||||
wantWarningReasons := []string{
|
wantDiagnosticReasons := []string{
|
||||||
spellnormalize.ReasonCodeSpellNameCanonicalized,
|
spellnormalize.ReasonCodeSpellNameCanonicalized,
|
||||||
spellnormalize.ReasonCodeSourceReferencesNormalized,
|
spellnormalize.ReasonCodeSourceReferencesNormalized,
|
||||||
spellnormalize.ReasonCodeDuplicateSpellCastCollapsed,
|
spellnormalize.ReasonCodeDuplicateSpellCastCollapsed,
|
||||||
"spell_not_near_source",
|
"spell_not_near_source",
|
||||||
}
|
}
|
||||||
gotWarningReasons := make([]string, len(output.Diagnostics.Groups))
|
gotDiagnosticReasons := make([]string, len(output.Diagnostics.Groups))
|
||||||
for index, group := range output.Diagnostics.Groups {
|
for index, group := range output.Diagnostics.Groups {
|
||||||
gotWarningReasons[index] = group.ReasonCode
|
gotDiagnosticReasons[index] = group.ReasonCode
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(gotWarningReasons, wantWarningReasons) {
|
if !reflect.DeepEqual(gotDiagnosticReasons, wantDiagnosticReasons) {
|
||||||
t.Fatalf("diagnostics = %#v, want deterministic normalize and validation diagnostics", output.Diagnostics)
|
t.Fatalf("diagnostics = %#v, want deterministic normalize and validation diagnostics", output.Diagnostics)
|
||||||
}
|
}
|
||||||
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]") {
|
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]") {
|
||||||
@@ -173,7 +173,7 @@ func TestAssembledSpellPipelineHonorsNormalizeValidatorOverride(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAssembledSpellPipelinePromotesTerminalUnknownSpellWarning(t *testing.T) {
|
func TestAssembledSpellPipelinePromotesTerminalUnknownSpellDiagnostics(t *testing.T) {
|
||||||
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true})
|
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{unknownSpell: true})
|
||||||
resolved.Steps[0].ArtifactLanes[0].NormalizeValidationPolicy.SemanticRejection = pipeline.SemanticRejectionRejectOutput
|
resolved.Steps[0].ArtifactLanes[0].NormalizeValidationPolicy.SemanticRejection = pipeline.SemanticRejectionRejectOutput
|
||||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||||
@@ -206,7 +206,7 @@ func TestAssembledSpellPipelinePromotesTerminalUnknownSpellWarning(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAssembledSpellPipelinePromotesUnknownSpellWarningWhenOverrideAccepts(t *testing.T) {
|
func TestAssembledSpellPipelinePromotesUnknownSpellAdvisoryWhenOverrideAccepts(t *testing.T) {
|
||||||
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true, unknownSpell: true})
|
registries, resolved, _ := assembledSpellPipeline(t, assembledSpellPipelineOptions{normalizeValidatorOverride: true, unknownSpell: true})
|
||||||
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -471,7 +471,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
|||||||
Debug: debugRecorder,
|
Debug: debugRecorder,
|
||||||
ExtractWorkers: cfg.Concurrency.StageWorkers["extract"],
|
ExtractWorkers: cfg.Concurrency.StageWorkers["extract"],
|
||||||
})
|
})
|
||||||
commandState.observeOutput(output)
|
diagnosticProjection, diagnosticErr := contracts.ProjectDiagnosticCollection(output.Diagnostics)
|
||||||
|
if diagnosticErr == nil {
|
||||||
|
commandState.observeOutput(output, diagnosticProjection)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
primaryErr := fmt.Errorf("run pipeline %q: %w", pipelineID, err)
|
primaryErr := fmt.Errorf("run pipeline %q: %w", pipelineID, err)
|
||||||
if output.Manifest.PipelineID != "" {
|
if output.Manifest.PipelineID != "" {
|
||||||
@@ -479,15 +482,21 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
|||||||
return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr, fmt.Errorf("write debug summary: %w", summaryErr))
|
return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr, fmt.Errorf("write debug summary: %w", summaryErr))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if diagnosticErr != nil {
|
||||||
|
return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr, fmt.Errorf("summarize run diagnostics: %w", diagnosticErr))
|
||||||
|
}
|
||||||
return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr)
|
return failPipelineCommand(stderr, commandState, terminalWriter, primaryErr)
|
||||||
}
|
}
|
||||||
|
if diagnosticErr != nil {
|
||||||
|
return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("summarize run diagnostics: %w", diagnosticErr))
|
||||||
|
}
|
||||||
|
|
||||||
if err := writePartialSummary(summary, output); err != nil {
|
if err := writePartialSummary(summary, output); err != nil {
|
||||||
return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("write debug summary: %w", err))
|
return failPipelineCommand(stderr, commandState, terminalWriter, fmt.Errorf("write debug summary: %w", err))
|
||||||
}
|
}
|
||||||
var encodedResult []byte
|
var encodedResult []byte
|
||||||
if *machineOutput {
|
if *machineOutput {
|
||||||
result, err := newRunResult(effective.ResolvedPipeline, output, runOutputDir, debugPath)
|
result, err := newRunResultWithDiagnostics(effective.ResolvedPipeline, output, runOutputDir, debugPath, diagnosticProjection)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failPipelineCommand(stderr, commandState, terminalWriter, err)
|
return failPipelineCommand(stderr, commandState, terminalWriter, err)
|
||||||
}
|
}
|
||||||
@@ -513,12 +522,25 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
|
|||||||
fmt.Fprintf(stdout, "debug=%s\n", debugPath)
|
fmt.Fprintf(stdout, "debug=%s\n", debugPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if warningGroups := warningGroupCount(output.Diagnostics); warningGroups > 0 {
|
if warningGroups := len(diagnosticProjection.Warnings); warningGroups > 0 {
|
||||||
fmt.Fprintf(stderr, "notarius: run completed with %d warning group(s), %d occurrence(s); details=%s\n", warningGroups, warningOccurrenceCount(output.Diagnostics), filepath.Join(runOutputDir, "warnings.json"))
|
fmt.Fprintf(stderr, "notarius: run completed with %d warning group(s), %d occurrence(s)", warningGroups, diagnosticProjection.WarningOccurrenceCount)
|
||||||
|
if warningFile, ok := logicalOutputFile(output.OutputFiles, "warnings.json"); ok {
|
||||||
|
fmt.Fprintf(stderr, "; details=%s", filepath.Join(runOutputDir, warningFile))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(stderr)
|
||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logicalOutputFile(files []contracts.OutputFile, name string) (string, bool) {
|
||||||
|
for _, file := range files {
|
||||||
|
if file.Name == name {
|
||||||
|
return file.Name, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
func writeSummary(summary *debugbundle.SummaryWriter, write func() error) error {
|
func writeSummary(summary *debugbundle.SummaryWriter, write func() error) error {
|
||||||
if summary == nil {
|
if summary == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) {
|
|||||||
roots := newStateTestRoots(t)
|
roots := newStateTestRoots(t)
|
||||||
harness := newStateTestHarness()
|
harness := newStateTestHarness()
|
||||||
harness.includeWarnings = true
|
harness.includeWarnings = true
|
||||||
|
harness.includeWarningFile = true
|
||||||
harness.chunkDiagnostics = []contracts.ProducerDiagnostic{stateTestDiagnostic("chunk", "contract-warning", "warning retained")}
|
harness.chunkDiagnostics = []contracts.ProducerDiagnostic{stateTestDiagnostic("chunk", "contract-warning", "warning retained")}
|
||||||
var stdout, stderr bytes.Buffer
|
var stdout, stderr bytes.Buffer
|
||||||
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options())
|
code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--debug"}, &stdout, &stderr, harness.options())
|
||||||
@@ -601,6 +602,7 @@ func TestRunWarningsRemainSuccessfulAndReachDurableSurfaces(t *testing.T) {
|
|||||||
if err != nil || !strings.Contains(string(output), "contract-warning") {
|
if err != nil || !strings.Contains(string(output), "contract-warning") {
|
||||||
t.Fatalf("durable output = %q, %v", output, err)
|
t.Fatalf("durable output = %q, %v", output, err)
|
||||||
}
|
}
|
||||||
|
assertFile(t, filepath.Join(filepath.Dir(outputPath), "warnings.json"))
|
||||||
bundle := onlyChildDir(t, roots.debug)
|
bundle := onlyChildDir(t, roots.debug)
|
||||||
var diagnostics contracts.DiagnosticCollection
|
var diagnostics contracts.DiagnosticCollection
|
||||||
readStateTestSummaryJSON(t, bundle, "final-diagnostics.json", &diagnostics)
|
readStateTestSummaryJSON(t, bundle, "final-diagnostics.json", &diagnostics)
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ type runResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, outputDirectory, debugDirectory string) (runResult, error) {
|
func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, outputDirectory, debugDirectory string) (runResult, error) {
|
||||||
|
diagnosticProjection, err := contracts.ProjectDiagnosticCollection(output.Diagnostics)
|
||||||
|
if err != nil {
|
||||||
|
return runResult{}, fmt.Errorf("summarize run diagnostics: %w", err)
|
||||||
|
}
|
||||||
|
return newRunResultWithDiagnostics(resolved, output, outputDirectory, debugDirectory, diagnosticProjection)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRunResultWithDiagnostics(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput, outputDirectory, debugDirectory string, diagnosticProjection contracts.DiagnosticProjection) (runResult, error) {
|
||||||
if strings.TrimSpace(output.Manifest.RunID) == "" {
|
if strings.TrimSpace(output.Manifest.RunID) == "" {
|
||||||
return runResult{}, fmt.Errorf("run result requires a run ID")
|
return runResult{}, fmt.Errorf("run result requires a run ID")
|
||||||
}
|
}
|
||||||
@@ -64,10 +72,10 @@ func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput,
|
|||||||
OutputDirectory: absOutputDirectory,
|
OutputDirectory: absOutputDirectory,
|
||||||
NormalizedOutputCount: len(output.NormalizeOutputs),
|
NormalizedOutputCount: len(output.NormalizeOutputs),
|
||||||
RejectedOutputCount: len(output.Rejected),
|
RejectedOutputCount: len(output.Rejected),
|
||||||
WarningGroupCount: warningGroupCount(output.Diagnostics),
|
WarningGroupCount: len(diagnosticProjection.Warnings),
|
||||||
WarningOccurrenceCount: warningOccurrenceCount(output.Diagnostics),
|
WarningOccurrenceCount: diagnosticProjection.WarningOccurrenceCount,
|
||||||
DiagnosticGroupCount: diagnosticGroupCount(output.Diagnostics),
|
DiagnosticGroupCount: len(diagnosticProjection.Diagnostics),
|
||||||
DiagnosticOccurrenceCount: diagnosticOccurrenceCount(output.Diagnostics),
|
DiagnosticOccurrenceCount: diagnosticProjection.DiagnosticOccurrenceCount,
|
||||||
DiagnosticsTruncated: output.Diagnostics.Truncated,
|
DiagnosticsTruncated: output.Diagnostics.Truncated,
|
||||||
ValidationStatus: output.Manifest.ValidationStatus,
|
ValidationStatus: output.Manifest.ValidationStatus,
|
||||||
ValidationSummaries: cloneValidationSummaries(output.Manifest.ValidationSummaries),
|
ValidationSummaries: cloneValidationSummaries(output.Manifest.ValidationSummaries),
|
||||||
@@ -97,44 +105,6 @@ func newRunResult(resolved pipeline.ResolvedPipeline, output pipeline.RunOutput,
|
|||||||
return result, nil
|
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 warningOccurrenceCount(diagnostics contracts.DiagnosticCollection) int {
|
|
||||||
return occurrenceCountByDisposition(diagnostics, contracts.DiagnosticDispositionWarning, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnosticGroupCount(diagnostics contracts.DiagnosticCollection) int {
|
|
||||||
count := 0
|
|
||||||
for _, group := range diagnostics.Groups {
|
|
||||||
if group.Disposition != contracts.DiagnosticDispositionWarning {
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnosticOccurrenceCount(diagnostics contracts.DiagnosticCollection) int {
|
|
||||||
return occurrenceCountByDisposition(diagnostics, contracts.DiagnosticDispositionWarning, false) + diagnostics.UnrepresentedOccurrenceCount
|
|
||||||
}
|
|
||||||
|
|
||||||
func occurrenceCountByDisposition(diagnostics contracts.DiagnosticCollection, disposition contracts.DiagnosticDisposition, include bool) int {
|
|
||||||
count := 0
|
|
||||||
for _, group := range diagnostics.Groups {
|
|
||||||
if (group.Disposition == disposition) == include {
|
|
||||||
count += group.OccurrenceCount
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneValidationSummaries(summaries []artifacts.ValidationSummary) []artifacts.ValidationSummary {
|
func cloneValidationSummaries(summaries []artifacts.ValidationSummary) []artifacts.ValidationSummary {
|
||||||
if len(summaries) == 0 {
|
if len(summaries) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func TestRunResultReportsWarningsAndDebugBundle(t *testing.T) {
|
|||||||
"run", "sample", "--config", roots.config, "--input", roots.input,
|
"run", "sample", "--config", roots.config, "--input", roots.input,
|
||||||
"--chunk_cache", "bypass", "--debug", "--json",
|
"--chunk_cache", "bypass", "--debug", "--json",
|
||||||
}, &stdout, &stderr, harness.options())
|
}, &stdout, &stderr, harness.options())
|
||||||
if code != 0 || !strings.Contains(stderr.String(), "1 warning group(s), 1 occurrence(s)") || !strings.Contains(stderr.String(), "warnings.json") {
|
if code != 0 || !strings.Contains(stderr.String(), "1 warning group(s), 1 occurrence(s)") || strings.Contains(stderr.String(), "warnings.json") {
|
||||||
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,11 @@ func testRunOutput() pipeline.RunOutput {
|
|||||||
Rejected: []contracts.RejectedOutput{{}},
|
Rejected: []contracts.RejectedOutput{{}},
|
||||||
Diagnostics: contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{{
|
Diagnostics: contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{{
|
||||||
Disposition: contracts.DiagnosticDispositionWarning,
|
Disposition: contracts.DiagnosticDispositionWarning,
|
||||||
|
Category: contracts.DiagnosticCategoryFallback,
|
||||||
|
ReasonCode: "fallback",
|
||||||
|
Origin: contracts.DiagnosticOrigin{Stage: contracts.DiagnosticOriginStageNormalize, StepID: "step", LaneID: "lane", ModuleKey: "module"},
|
||||||
OccurrenceCount: 1,
|
OccurrenceCount: 1,
|
||||||
|
Samples: []contracts.DiagnosticSample{{Scope: "scope", Message: "message"}},
|
||||||
}}},
|
}}},
|
||||||
OutputFiles: []contracts.OutputFile{{Name: "index.json"}},
|
OutputFiles: []contracts.OutputFile{{Name: "index.json"}},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,16 +34,16 @@ func (s *pipelineCommandState) setDebugPath(debugPath string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *pipelineCommandState) observeOutput(output pipeline.RunOutput) {
|
func (s *pipelineCommandState) observeOutput(output pipeline.RunOutput, diagnostics contracts.DiagnosticProjection) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.report.OutputCount = len(output.NormalizeOutputs)
|
s.report.OutputCount = len(output.NormalizeOutputs)
|
||||||
s.report.RejectedCount = len(output.Rejected)
|
s.report.RejectedCount = len(output.Rejected)
|
||||||
s.report.WarningGroupCount = warningGroupCount(output.Diagnostics)
|
s.report.WarningGroupCount = len(diagnostics.Warnings)
|
||||||
s.report.WarningOccurrenceCount = warningOccurrenceCount(output.Diagnostics)
|
s.report.WarningOccurrenceCount = diagnostics.WarningOccurrenceCount
|
||||||
s.report.DiagnosticGroupCount = diagnosticGroupCount(output.Diagnostics)
|
s.report.DiagnosticGroupCount = len(diagnostics.Diagnostics)
|
||||||
s.report.DiagnosticOccurrenceCount = diagnosticOccurrenceCount(output.Diagnostics)
|
s.report.DiagnosticOccurrenceCount = diagnostics.DiagnosticOccurrenceCount
|
||||||
s.report.DiagnosticsTruncated = output.Diagnostics.Truncated
|
s.report.DiagnosticsTruncated = output.Diagnostics.Truncated
|
||||||
s.report.ValidationStatus = output.Manifest.ValidationStatus
|
s.report.ValidationStatus = output.Manifest.ValidationStatus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ import (
|
|||||||
func TestProductionSpellCatalogValidationRetries(t *testing.T) {
|
func TestProductionSpellCatalogValidationRetries(t *testing.T) {
|
||||||
const retries = 2
|
const retries = 2
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
responses []string
|
responses []string
|
||||||
wantCalls int
|
wantCalls int
|
||||||
wantRejected bool
|
wantRejected bool
|
||||||
wantSpell string
|
wantSpell string
|
||||||
wantWarningCode string
|
wantAdvisoryCode string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "unknown spell remains rejected after exhaustion",
|
name: "unknown spell remains rejected after exhaustion",
|
||||||
@@ -42,9 +42,9 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) {
|
|||||||
productionSpellResponse("Unknown Spell"),
|
productionSpellResponse("Unknown Spell"),
|
||||||
productionSpellResponse("Aegis of Emberfall"),
|
productionSpellResponse("Aegis of Emberfall"),
|
||||||
},
|
},
|
||||||
wantCalls: 2,
|
wantCalls: 2,
|
||||||
wantSpell: "Aegis of Emberfall",
|
wantSpell: "Aegis of Emberfall",
|
||||||
wantWarningCode: "spell_not_near_source",
|
wantAdvisoryCode: "spell_not_near_source",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,14 +108,14 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) {
|
|||||||
if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell {
|
if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell {
|
||||||
t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value)
|
t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value)
|
||||||
}
|
}
|
||||||
if len(output.Diagnostics.Groups) != 2 || output.Diagnostics.Groups[0].ReasonCode != tt.wantWarningCode {
|
if len(output.Diagnostics.Groups) != 2 || output.Diagnostics.Groups[0].ReasonCode != tt.wantAdvisoryCode {
|
||||||
t.Fatalf("diagnostics = %#v, want terminal extract and normalize diagnostics", output.Diagnostics)
|
t.Fatalf("diagnostics = %#v, want terminal extract and normalize diagnostics", output.Diagnostics)
|
||||||
}
|
}
|
||||||
if len(output.Diagnostics.Groups) != 2 {
|
if len(output.Diagnostics.Groups) != 2 {
|
||||||
t.Fatalf("diagnostics = %#v, want extract and normalize data-quality advisories", output.Diagnostics)
|
t.Fatalf("diagnostics = %#v, want extract and normalize data-quality advisories", output.Diagnostics)
|
||||||
}
|
}
|
||||||
for _, diagnostic := range output.Diagnostics.Groups {
|
for _, diagnostic := range output.Diagnostics.Groups {
|
||||||
if diagnostic.Disposition != contracts.DiagnosticDispositionAdvisory || diagnostic.ReasonCode != tt.wantWarningCode {
|
if diagnostic.Disposition != contracts.DiagnosticDispositionAdvisory || diagnostic.ReasonCode != tt.wantAdvisoryCode {
|
||||||
t.Fatalf("diagnostics = %#v, want only data-quality advisories", output.Diagnostics)
|
t.Fatalf("diagnostics = %#v, want only data-quality advisories", output.Diagnostics)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -868,6 +868,7 @@ type stateTestHarness struct {
|
|||||||
sessionIDs []string
|
sessionIDs []string
|
||||||
outputDiagnostics contracts.DiagnosticCollection
|
outputDiagnostics contracts.DiagnosticCollection
|
||||||
includeWarnings bool
|
includeWarnings bool
|
||||||
|
includeWarningFile bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} }
|
func newStateTestHarness() *stateTestHarness { return &stateTestHarness{} }
|
||||||
@@ -1012,7 +1013,11 @@ func (o stateTestOutput) Encode(_ context.Context, req contracts.OutputRequest)
|
|||||||
if o.includeDiagnostics && len(req.Diagnostics.Groups) > 0 {
|
if o.includeDiagnostics && len(req.Diagnostics.Groups) > 0 {
|
||||||
data = []byte(fmt.Sprintf("{\"ok\":true,\"diagnostics\":%q}\n", req.Diagnostics.Groups[0].ReasonCode))
|
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
|
files := []contracts.OutputFile{{Name: "result.json", Bytes: data}}
|
||||||
|
if o.harness.includeWarningFile {
|
||||||
|
files = append(files, contracts.OutputFile{Name: "warnings.json", Bytes: []byte("{\"warnings\":true}\n")})
|
||||||
|
}
|
||||||
|
return contracts.OutputResult{Files: files}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func stateTestDiagnostic(scope, reasonCode, message string) contracts.ProducerDiagnostic {
|
func stateTestDiagnostic(scope, reasonCode, message string) contracts.ProducerDiagnostic {
|
||||||
|
|||||||
@@ -126,6 +126,9 @@ func (candidate ModelCandidate) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ValidateValidationResult(result ValidationResult) error {
|
func ValidateValidationResult(result ValidationResult) error {
|
||||||
|
if err := ValidateProducerDiagnostics(result.Diagnostics); err != nil {
|
||||||
|
return fmt.Errorf("validation diagnostics: %w", err)
|
||||||
|
}
|
||||||
if !result.Approved && result.ReasonCode == "" {
|
if !result.Approved && result.ReasonCode == "" {
|
||||||
return errors.New("validation rejection reason code must not be empty")
|
return errors.New("validation rejection reason code must not be empty")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ func TestCorrectionContractsRejectInvalidContent(t *testing.T) {
|
|||||||
{"oversized correction guidance", func() error {
|
{"oversized correction guidance", func() error {
|
||||||
return ValidateValidationResult(ValidationResult{ReasonCode: "invalid", CorrectionGuidance: tooLongValidationGuidance})
|
return ValidateValidationResult(ValidationResult{ReasonCode: "invalid", CorrectionGuidance: tooLongValidationGuidance})
|
||||||
}},
|
}},
|
||||||
|
{"invalid diagnostics", func() error {
|
||||||
|
return ValidateValidationResult(ValidationResult{Approved: true, Diagnostics: []ProducerDiagnostic{{}}})
|
||||||
|
}},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if err := test.call(); err == nil {
|
if err := test.call(); err == nil {
|
||||||
|
|||||||
@@ -102,6 +102,16 @@ type DiagnosticCollection struct {
|
|||||||
UnrepresentedOccurrenceCount int `json:"unrepresented_occurrence_count"`
|
UnrepresentedOccurrenceCount int `json:"unrepresented_occurrence_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DiagnosticProjection is the validated warning/non-warning view used by
|
||||||
|
// durable and presentation boundaries. Occurrence totals are checked before
|
||||||
|
// they leave the framework contract.
|
||||||
|
type DiagnosticProjection struct {
|
||||||
|
Warnings []DiagnosticGroup
|
||||||
|
Diagnostics []DiagnosticGroup
|
||||||
|
WarningOccurrenceCount int
|
||||||
|
DiagnosticOccurrenceCount int
|
||||||
|
}
|
||||||
|
|
||||||
// Validate checks a producer-local diagnostic against the public safety and
|
// Validate checks a producer-local diagnostic against the public safety and
|
||||||
// classification contract.
|
// classification contract.
|
||||||
func (diagnostic ProducerDiagnostic) Validate() error {
|
func (diagnostic ProducerDiagnostic) Validate() error {
|
||||||
@@ -192,6 +202,41 @@ func (collection DiagnosticCollection) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProjectDiagnosticCollection validates, partitions, and totals one finalized
|
||||||
|
// collection. Unrepresented occurrences belong to the non-warning projection.
|
||||||
|
func ProjectDiagnosticCollection(collection DiagnosticCollection) (DiagnosticProjection, error) {
|
||||||
|
if err := collection.Validate(); err != nil {
|
||||||
|
return DiagnosticProjection{}, err
|
||||||
|
}
|
||||||
|
projection := DiagnosticProjection{
|
||||||
|
Warnings: make([]DiagnosticGroup, 0),
|
||||||
|
Diagnostics: make([]DiagnosticGroup, 0),
|
||||||
|
}
|
||||||
|
for _, group := range collection.Groups {
|
||||||
|
if group.Disposition == DiagnosticDispositionWarning {
|
||||||
|
count, err := addDiagnosticOccurrences(projection.WarningOccurrenceCount, group.OccurrenceCount)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticProjection{}, fmt.Errorf("warning occurrences: %w", err)
|
||||||
|
}
|
||||||
|
projection.WarningOccurrenceCount = count
|
||||||
|
projection.Warnings = append(projection.Warnings, cloneDiagnosticGroup(group))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count, err := addDiagnosticOccurrences(projection.DiagnosticOccurrenceCount, group.OccurrenceCount)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticProjection{}, fmt.Errorf("diagnostic occurrences: %w", err)
|
||||||
|
}
|
||||||
|
projection.DiagnosticOccurrenceCount = count
|
||||||
|
projection.Diagnostics = append(projection.Diagnostics, cloneDiagnosticGroup(group))
|
||||||
|
}
|
||||||
|
count, err := addDiagnosticOccurrences(projection.DiagnosticOccurrenceCount, collection.UnrepresentedOccurrenceCount)
|
||||||
|
if err != nil {
|
||||||
|
return DiagnosticProjection{}, fmt.Errorf("diagnostic occurrences: %w", err)
|
||||||
|
}
|
||||||
|
projection.DiagnosticOccurrenceCount = count
|
||||||
|
return projection, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CloneProducerDiagnostics returns independent diagnostic slice ownership.
|
// CloneProducerDiagnostics returns independent diagnostic slice ownership.
|
||||||
func CloneProducerDiagnostics(diagnostics []ProducerDiagnostic) []ProducerDiagnostic {
|
func CloneProducerDiagnostics(diagnostics []ProducerDiagnostic) []ProducerDiagnostic {
|
||||||
if len(diagnostics) == 0 {
|
if len(diagnostics) == 0 {
|
||||||
@@ -305,6 +350,13 @@ func cloneDiagnosticGroup(group DiagnosticGroup) DiagnosticGroup {
|
|||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func addDiagnosticOccurrences(current, incoming int) (int, error) {
|
||||||
|
if incoming > int(^uint(0)>>1)-current {
|
||||||
|
return 0, errors.New("occurrence count overflow")
|
||||||
|
}
|
||||||
|
return current + incoming, nil
|
||||||
|
}
|
||||||
|
|
||||||
func cloneDiagnosticSamples(samples []DiagnosticSample) []DiagnosticSample {
|
func cloneDiagnosticSamples(samples []DiagnosticSample) []DiagnosticSample {
|
||||||
if len(samples) == 0 {
|
if len(samples) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -146,6 +146,47 @@ func TestCloneDiagnosticCollectionOwnsGroupsAndChunkIndex(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProjectDiagnosticCollectionPartitionsAndChecksTotals(t *testing.T) {
|
||||||
|
warning := validDiagnosticGroup(DiagnosticDispositionWarning, DiagnosticCategoryFallback, "fallback", 2)
|
||||||
|
diagnostic := validDiagnosticGroup(DiagnosticDispositionAdvisory, DiagnosticCategoryDataQuality, "quality", 3)
|
||||||
|
collection := DiagnosticCollection{
|
||||||
|
Groups: []DiagnosticGroup{warning, diagnostic},
|
||||||
|
Truncated: true,
|
||||||
|
UnrepresentedOccurrenceCount: 4,
|
||||||
|
}
|
||||||
|
projection, err := ProjectDiagnosticCollection(collection)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(projection.Warnings) != 1 || len(projection.Diagnostics) != 1 || projection.WarningOccurrenceCount != 2 || projection.DiagnosticOccurrenceCount != 7 {
|
||||||
|
t.Fatalf("projection = %#v, want partitioned exact totals", projection)
|
||||||
|
}
|
||||||
|
collection.Groups[0].Samples[0].Message = "mutated"
|
||||||
|
if projection.Warnings[0].Samples[0].Message != "message" {
|
||||||
|
t.Fatal("projection retained caller-owned sample storage")
|
||||||
|
}
|
||||||
|
|
||||||
|
overflow := DiagnosticCollection{Groups: []DiagnosticGroup{
|
||||||
|
validDiagnosticGroup(DiagnosticDispositionWarning, DiagnosticCategoryFallback, "first", int(^uint(0)>>1)),
|
||||||
|
validDiagnosticGroup(DiagnosticDispositionWarning, DiagnosticCategoryFallback, "second", 1),
|
||||||
|
}}
|
||||||
|
if _, err := ProjectDiagnosticCollection(overflow); err == nil {
|
||||||
|
t.Fatal("ProjectDiagnosticCollection() overflow error = nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validDiagnosticGroup(disposition DiagnosticDisposition, category DiagnosticCategory, reason string, occurrences int) DiagnosticGroup {
|
||||||
|
return DiagnosticGroup{
|
||||||
|
Disposition: disposition,
|
||||||
|
Category: category,
|
||||||
|
ReasonCode: reason,
|
||||||
|
Origin: DiagnosticOrigin{Stage: DiagnosticOriginStageNormalize, StepID: "step", LaneID: "lane", ModuleKey: "module"},
|
||||||
|
OccurrenceCount: occurrences,
|
||||||
|
Samples: []DiagnosticSample{{Scope: "scope", Message: "message"}},
|
||||||
|
OmittedSampleCount: occurrences - 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validProducerDiagnostic() ProducerDiagnostic {
|
func validProducerDiagnostic() ProducerDiagnostic {
|
||||||
return ProducerDiagnostic{
|
return ProducerDiagnostic{
|
||||||
Disposition: DiagnosticDispositionWarning,
|
Disposition: DiagnosticDispositionWarning,
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ const (
|
|||||||
type Aggregator struct {
|
type Aggregator struct {
|
||||||
groups []contracts.DiagnosticGroup
|
groups []contracts.DiagnosticGroup
|
||||||
indices map[groupKey]int
|
indices map[groupKey]int
|
||||||
omitted map[groupKey]struct{}
|
|
||||||
warningGroups int
|
warningGroups int
|
||||||
nonWarningGroups int
|
nonWarningGroups int
|
||||||
|
warningOccurrences int
|
||||||
|
nonWarningOccurrences int
|
||||||
unrepresentedOccurrences int
|
unrepresentedOccurrences int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,31 +33,62 @@ func (aggregator *Aggregator) Add(group contracts.DiagnosticGroup) error {
|
|||||||
}
|
}
|
||||||
if aggregator.indices == nil {
|
if aggregator.indices == nil {
|
||||||
aggregator.indices = make(map[groupKey]int)
|
aggregator.indices = make(map[groupKey]int)
|
||||||
aggregator.omitted = make(map[groupKey]struct{})
|
|
||||||
}
|
}
|
||||||
key := groupKeyFromGroup(group)
|
key := groupKeyFromGroup(group)
|
||||||
if index, exists := aggregator.indices[key]; exists {
|
if index, exists := aggregator.indices[key]; exists {
|
||||||
return aggregator.merge(index, group)
|
if err := aggregator.checkOccurrenceTotal(group.Disposition, group.OccurrenceCount); err != nil {
|
||||||
}
|
return err
|
||||||
if _, exists := aggregator.omitted[key]; exists {
|
}
|
||||||
return aggregator.addUnrepresented(group.OccurrenceCount)
|
if err := aggregator.merge(index, group); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
aggregator.addOccurrenceTotal(group.Disposition, group.OccurrenceCount)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if group.Disposition == contracts.DiagnosticDispositionWarning {
|
if group.Disposition == contracts.DiagnosticDispositionWarning {
|
||||||
if aggregator.warningGroups >= MaxWarningGroups {
|
if aggregator.warningGroups >= MaxWarningGroups {
|
||||||
return errors.New("diagnostic warning groups exceed maximum count")
|
return errors.New("diagnostic warning groups exceed maximum count")
|
||||||
}
|
}
|
||||||
aggregator.warningGroups++
|
|
||||||
} else if aggregator.nonWarningGroups >= MaxNonWarningGroups {
|
} else if aggregator.nonWarningGroups >= MaxNonWarningGroups {
|
||||||
aggregator.omitted[key] = struct{}{}
|
if err := aggregator.checkOccurrenceTotal(group.Disposition, group.OccurrenceCount); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
aggregator.addOccurrenceTotal(group.Disposition, group.OccurrenceCount)
|
||||||
return aggregator.addUnrepresented(group.OccurrenceCount)
|
return aggregator.addUnrepresented(group.OccurrenceCount)
|
||||||
|
}
|
||||||
|
if err := aggregator.checkOccurrenceTotal(group.Disposition, group.OccurrenceCount); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if group.Disposition == contracts.DiagnosticDispositionWarning {
|
||||||
|
aggregator.warningGroups++
|
||||||
} else {
|
} else {
|
||||||
aggregator.nonWarningGroups++
|
aggregator.nonWarningGroups++
|
||||||
}
|
}
|
||||||
|
aggregator.addOccurrenceTotal(group.Disposition, group.OccurrenceCount)
|
||||||
aggregator.indices[key] = len(aggregator.groups)
|
aggregator.indices[key] = len(aggregator.groups)
|
||||||
aggregator.groups = append(aggregator.groups, contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{group}}).Groups[0])
|
aggregator.groups = append(aggregator.groups, contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: []contracts.DiagnosticGroup{group}}).Groups[0])
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (aggregator *Aggregator) checkOccurrenceTotal(disposition contracts.DiagnosticDisposition, count int) error {
|
||||||
|
current := aggregator.nonWarningOccurrences
|
||||||
|
if disposition == contracts.DiagnosticDispositionWarning {
|
||||||
|
current = aggregator.warningOccurrences
|
||||||
|
}
|
||||||
|
if count > maximumInt()-current {
|
||||||
|
return errors.New("diagnostic occurrence count overflow")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (aggregator *Aggregator) addOccurrenceTotal(disposition contracts.DiagnosticDisposition, count int) {
|
||||||
|
if disposition == contracts.DiagnosticDispositionWarning {
|
||||||
|
aggregator.warningOccurrences += count
|
||||||
|
return
|
||||||
|
}
|
||||||
|
aggregator.nonWarningOccurrences += count
|
||||||
|
}
|
||||||
|
|
||||||
// Collection returns an independently owned grouped result in first-occurrence
|
// Collection returns an independently owned grouped result in first-occurrence
|
||||||
// order.
|
// order.
|
||||||
func (aggregator *Aggregator) Collection() contracts.DiagnosticCollection {
|
func (aggregator *Aggregator) Collection() contracts.DiagnosticCollection {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestAggregatorEnforcesWarningBoundAndTruncatesOnlyNonWarnings(t *testing.T)
|
|||||||
}
|
}
|
||||||
|
|
||||||
nonWarnings := Aggregator{}
|
nonWarnings := Aggregator{}
|
||||||
for index := 0; index < MaxNonWarningGroups+1; index++ {
|
for index := 0; index < MaxNonWarningGroups+3; index++ {
|
||||||
group := groupForAggregation("scope", "message", contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality, "module")
|
group := groupForAggregation("scope", "message", contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality, "module")
|
||||||
group.ReasonCode = "advisory-" + string(rune('a'+index))
|
group.ReasonCode = "advisory-" + string(rune('a'+index))
|
||||||
if err := nonWarnings.Add(group); err != nil {
|
if err := nonWarnings.Add(group); err != nil {
|
||||||
@@ -54,8 +54,26 @@ func TestAggregatorEnforcesWarningBoundAndTruncatesOnlyNonWarnings(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
collection := nonWarnings.Collection()
|
collection := nonWarnings.Collection()
|
||||||
if len(collection.Groups) != MaxNonWarningGroups || !collection.Truncated || collection.UnrepresentedOccurrenceCount != 1 {
|
if len(collection.Groups) != MaxNonWarningGroups || !collection.Truncated || collection.UnrepresentedOccurrenceCount != 3 {
|
||||||
t.Fatalf("collection = %#v, want bounded non-warning groups and one unrepresented occurrence", collection)
|
t.Fatalf("collection = %#v, want bounded non-warning groups and three unrepresented occurrences", collection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAggregatorRejectsOccurrenceTotalOverflow(t *testing.T) {
|
||||||
|
aggregator := Aggregator{}
|
||||||
|
first := groupForAggregation("first", "first", contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality, "first")
|
||||||
|
first.OccurrenceCount = maximumInt()
|
||||||
|
first.OmittedSampleCount = maximumInt() - 1
|
||||||
|
if err := aggregator.Add(first); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := groupForAggregation("second", "second", contracts.DiagnosticDispositionAdvisory, contracts.DiagnosticCategoryDataQuality, "second")
|
||||||
|
if err := aggregator.Add(second); err == nil {
|
||||||
|
t.Fatal("Add() occurrence total overflow error = nil")
|
||||||
|
}
|
||||||
|
collection := aggregator.Collection()
|
||||||
|
if len(collection.Groups) != 1 || collection.Groups[0].OccurrenceCount != maximumInt() {
|
||||||
|
t.Fatalf("collection changed after rejected overflow = %#v", collection)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ func runProducerAttempts(ctx context.Context, config producerAttemptConfig, prod
|
|||||||
}
|
}
|
||||||
return failedProducerAttempt(provenance), fmt.Errorf("producer failed after %d attempt(s): %w", number, err)
|
return failedProducerAttempt(provenance), fmt.Errorf("producer failed after %d attempt(s): %w", number, err)
|
||||||
}
|
}
|
||||||
|
if err := validateProducerAttemptDiagnostics(output); err != nil {
|
||||||
|
provenance = append(provenance, producerAttemptProvenance{Number: number, Kind: kind, Outcome: producerAttemptFailed})
|
||||||
|
return failedProducerAttempt(provenance), err
|
||||||
|
}
|
||||||
|
|
||||||
output, err = output.clone()
|
output, err = output.clone()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -265,6 +269,18 @@ func runProducerAttempts(ctx context.Context, config producerAttemptConfig, prod
|
|||||||
return failedProducerAttempt(provenance), errors.New("producer attempt budget was not exhausted deterministically")
|
return failedProducerAttempt(provenance), errors.New("producer attempt budget was not exhausted deterministically")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateProducerAttemptDiagnostics(output producerAttemptOutput) error {
|
||||||
|
if err := contracts.ValidateProducerDiagnostics(output.Diagnostics); err != nil {
|
||||||
|
return fmt.Errorf("producer returned invalid diagnostics: %w", err)
|
||||||
|
}
|
||||||
|
if output.Retry != nil {
|
||||||
|
if err := contracts.ValidateProducerDiagnostics(output.Retry.FallbackDiagnostics); err != nil {
|
||||||
|
return fmt.Errorf("producer returned invalid retry fallback diagnostics: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func isImmediateProducerFailure(err error) bool {
|
func isImmediateProducerFailure(err error) bool {
|
||||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
@@ -247,6 +248,45 @@ func TestRunProducerAttemptsUsesModuleRetryBudgetAndFallback(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunProducerAttemptsRejectsInvalidDiagnosticsWithoutRetry(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
output producerAttemptOutput
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "producer diagnostics",
|
||||||
|
output: producerAttemptOutput{Diagnostics: []contracts.ProducerDiagnostic{{}}},
|
||||||
|
want: "producer returned invalid diagnostics",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "retry fallback diagnostics",
|
||||||
|
output: producerAttemptOutput{Retry: &producerRetryDirective{FallbackDiagnostics: []contracts.ProducerDiagnostic{{}}}},
|
||||||
|
want: "producer returned invalid retry fallback diagnostics",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
producerCalls := 0
|
||||||
|
validatorCalls := 0
|
||||||
|
terminal, err := runProducerAttempts(context.Background(), producerAttemptConfig{Retries: 3, Policy: DefaultValidationPolicy()}, func(context.Context, producerAttemptRequest) (producerAttemptOutput, error) {
|
||||||
|
producerCalls++
|
||||||
|
return test.output, nil
|
||||||
|
}, func(context.Context, producerAttemptOutput) (validationReport, error) {
|
||||||
|
validatorCalls++
|
||||||
|
return validationReport{}, nil
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("runProducerAttempts() error = %v, want %q", err, test.want)
|
||||||
|
}
|
||||||
|
if terminal.Action != producerTerminalFailed || producerCalls != 1 || validatorCalls != 0 {
|
||||||
|
t.Fatalf("terminal = %#v, producer calls = %d, validator calls = %d; want immediate framework failure", terminal, producerCalls, validatorCalls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunProducerAttemptsRejectionWinsOverValidatorFailure(t *testing.T) {
|
func TestRunProducerAttemptsRejectionWinsOverValidatorFailure(t *testing.T) {
|
||||||
firstDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("discarded", "discarded warning")}
|
firstDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("discarded", "discarded warning")}
|
||||||
secondDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted warning")}
|
secondDiagnostics := []contracts.ProducerDiagnostic{producerDiagnostic("accepted", "accepted warning")}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ func (r *Runner) runChunkPlan(ctx context.Context, input RunInput, doc *source.S
|
|||||||
return result, failure
|
return result, failure
|
||||||
}
|
}
|
||||||
// A cache hit is not model material. Its rejection is discarded and
|
// A cache hit is not model material. Its rejection is discarded and
|
||||||
// generation begins with the ordinary initial request below. Warnings
|
// generation begins with the ordinary initial request below. Diagnostics
|
||||||
// from this discarded candidate are intentionally not promoted.
|
// from this discarded candidate are intentionally not promoted.
|
||||||
}
|
}
|
||||||
result.lookup = ChunkPlanDecision{Status: ChunkPlanInvalid, Reason: chunkPlanLookupReason(ChunkPlanInvalid)}
|
result.lookup = ChunkPlanDecision{Status: ChunkPlanInvalid, Reason: chunkPlanLookupReason(ChunkPlanInvalid)}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const (
|
|||||||
const correctionRequestIntroduction = "The previous response failed semantic validation. Return one complete corrected replacement response, not a patch, explanation, or commentary.\n\nCorrect all of the following:\n"
|
const correctionRequestIntroduction = "The previous response failed semantic validation. Return one complete corrected replacement response, not a patch, explanation, or commentary.\n\nCorrect all of the following:\n"
|
||||||
|
|
||||||
// validationRecord captures the settled result of one configured validator.
|
// validationRecord captures the settled result of one configured validator.
|
||||||
// Its fields remain private so reports cannot expose mutable warning storage.
|
// Its fields remain private so reports cannot expose mutable diagnostic storage.
|
||||||
type validationRecord struct {
|
type validationRecord struct {
|
||||||
validatorName string
|
validatorName string
|
||||||
outcome validationOutcome
|
outcome validationOutcome
|
||||||
@@ -188,6 +188,9 @@ func executeValidationChain(ctx context.Context, chain preparedValidatorChain, i
|
|||||||
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationSkipped, attemptCount: attempt, reasonCode: invocation.reason, message: invocation.message})
|
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationSkipped, attemptCount: attempt, reasonCode: invocation.reason, message: invocation.message})
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
if err := contracts.ValidateProducerDiagnostics(invocation.result.Diagnostics); err != nil {
|
||||||
|
return validationReport{}, fatalValidationError(fmt.Errorf("validator %q returned invalid diagnostics: %w", binding.Module, err))
|
||||||
|
}
|
||||||
if err := contracts.ValidateValidationResult(invocation.result); err != nil {
|
if err := contracts.ValidateValidationResult(invocation.result); err != nil {
|
||||||
if attempt == attemptLimit {
|
if attempt == attemptLimit {
|
||||||
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator returned an invalid result", failure: err})
|
report.records = append(report.records, validationRecord{validatorName: binding.Module, outcome: validationFailed, attemptCount: attempt, message: "validator returned an invalid result", failure: err})
|
||||||
|
|||||||
@@ -196,6 +196,19 @@ func TestExecuteValidationChainReturnsFrameworkAndCancellationErrors(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExecuteValidationChainRejectsInvalidDiagnosticsWithoutRetry(t *testing.T) {
|
||||||
|
chain := validationChain(validationSpec("validator", contracts.ExecutionClassLLMBacked, 2))
|
||||||
|
calls := 0
|
||||||
|
_, err := executeValidationChain(context.Background(), chain, func(context.Context, preparedValidator, int) (validationInvocation, error) {
|
||||||
|
calls++
|
||||||
|
return validationInvocation{result: contracts.ValidationResult{Approved: true, Diagnostics: []contracts.ProducerDiagnostic{{}}}}, nil
|
||||||
|
})
|
||||||
|
var frameworkErr validationFrameworkError
|
||||||
|
if !errors.As(err, &frameworkErr) || calls != 1 || !strings.Contains(err.Error(), "validator \"validator\" returned invalid diagnostics") {
|
||||||
|
t.Fatalf("error = %v calls = %d, want one fatal invalid-diagnostics result", err, calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type validationStep struct {
|
type validationStep struct {
|
||||||
result contracts.ValidationResult
|
result contracts.ValidationResult
|
||||||
invocation validationInvocation
|
invocation validationInvocation
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ type ApplicationPolicy[T any] struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GroupProvenance identifies the complete input contribution of one plan
|
// GroupProvenance identifies the complete input contribution of one plan
|
||||||
// group without prescribing domain warning or retry policy.
|
// group without prescribing domain diagnostic or retry policy.
|
||||||
type GroupProvenance struct {
|
type GroupProvenance struct {
|
||||||
memberPositions []int
|
memberPositions []int
|
||||||
canonicalPosition int
|
canonicalPosition int
|
||||||
@@ -108,7 +108,7 @@ func (event AppliedGroup) Provenance() GroupProvenance {
|
|||||||
return cloneGroupProvenance(event.provenance)
|
return cloneGroupProvenance(event.provenance)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RejectedGroup records a typed guard decision while leaving warning and retry
|
// RejectedGroup records a typed guard decision while leaving diagnostic and retry
|
||||||
// construction to the consuming domain.
|
// construction to the consuming domain.
|
||||||
type RejectedGroup struct {
|
type RejectedGroup struct {
|
||||||
category RejectionCategory
|
category RejectionCategory
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Issue identifies an unsafe proposal category at its original response group
|
// Issue identifies an unsafe proposal category at its original response group
|
||||||
// index without prescribing caller warning text.
|
// index without prescribing caller diagnostic text.
|
||||||
type Issue struct {
|
type Issue struct {
|
||||||
GroupIndex int
|
GroupIndex int
|
||||||
Category IssueCategory
|
Category IssueCategory
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
order := shared.NewSourceRefOrderFromIndex(index)
|
order := shared.NewSourceRefOrderFromIndex(index)
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry)
|
value, findings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry)
|
||||||
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings)
|
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex,
|
|||||||
}
|
}
|
||||||
|
|
||||||
records := make([]normalizedRecord, len(input.CombatTurns))
|
records := make([]normalizedRecord, len(input.CombatTurns))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, inputTurn := range input.CombatTurns {
|
for index, inputTurn := range input.CombatTurns {
|
||||||
turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry)
|
turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry)
|
||||||
earliest, hasEvidence := order.EarliestValid(turn.SourceRefs)
|
earliest, hasEvidence := order.EarliestValid(turn.SourceRefs)
|
||||||
@@ -149,7 +149,7 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex,
|
|||||||
hasEvidence: hasEvidence,
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
if actorChange != nil {
|
if actorChange != nil {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: turnScope(index),
|
Scope: turnScope(index),
|
||||||
ReasonCode: ReasonCodeActorCanonicalized,
|
ReasonCode: ReasonCodeActorCanonicalized,
|
||||||
Message: fmt.Sprintf("input index %d: actor canonicalized from %s to %s",
|
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 {
|
if refsChanged {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: turnScope(index),
|
Scope: turnScope(index),
|
||||||
ReasonCode: ReasonCodeSourceRefsNormalized,
|
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
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 {
|
if position == record.inputIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: turnScope(record.inputIndex),
|
Scope: turnScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeTurnsReordered,
|
ReasonCode: ReasonCodeTurnsReordered,
|
||||||
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology",
|
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology",
|
||||||
@@ -187,9 +187,9 @@ func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex,
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
|
output, duplicateFindings := collapseDuplicates(records, documentIndex)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
findings = append(findings, duplicateFindings...)
|
||||||
return dnd.CombatTurnList{CombatTurns: output}, warnings
|
return dnd.CombatTurnList{CombatTurns: output}, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
|
func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
|
||||||
@@ -267,14 +267,14 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if len(group.removed) == 0 {
|
if len(group.removed) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed))
|
findings = append(findings, duplicateFinding(group.retainedIndex, group.removed))
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateKey(turn dnd.CombatTurn, documentIndex source.DocumentIndex) (string, bool) {
|
func duplicateKey(turn dnd.CombatTurn, documentIndex source.DocumentIndex) (string, bool) {
|
||||||
@@ -309,7 +309,7 @@ func writeKeyInt(builder *strings.Builder, value int) {
|
|||||||
builder.WriteByte(';')
|
builder.WriteByte(';')
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
issues := make([]string, len(removed))
|
issues := make([]string, len(removed))
|
||||||
for index, removedIndex := range removed {
|
for index, removedIndex := range removed {
|
||||||
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestNormalizeCanonicalizesFieldsAndRegistryIdentities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeLimitsWarningsWithoutChangingCombatTurnValues(t *testing.T) {
|
func TestNormalizeLimitsFindingsWithoutChangingCombatTurnValues(t *testing.T) {
|
||||||
units := make([]source.SourceUnit, contracts.MaxDiagnosticSamples+1)
|
units := make([]source.SourceUnit, contracts.MaxDiagnosticSamples+1)
|
||||||
turns := make([]dnd.CombatTurn, len(units))
|
turns := make([]dnd.CombatTurn, len(units))
|
||||||
for index := range units {
|
for index := range units {
|
||||||
@@ -288,23 +288,23 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDuplicateWarningBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) {
|
func TestDuplicateFindingBoundsDisplayedIndexesAndReportsAllOmissions(t *testing.T) {
|
||||||
removed := make([]int, 25)
|
removed := make([]int, 25)
|
||||||
for index := range removed {
|
for index := range removed {
|
||||||
removed[index] = math.MaxInt - index
|
removed[index] = math.MaxInt - index
|
||||||
}
|
}
|
||||||
warning := duplicateWarning(7, removed)
|
finding := duplicateFinding(7, removed)
|
||||||
if warning.Scope != "combat_turns[7]" || warning.ReasonCode != ReasonCodeDuplicateCollapsed {
|
if finding.Scope != "combat_turns[7]" || finding.ReasonCode != ReasonCodeDuplicateCollapsed {
|
||||||
t.Fatalf("duplicate warning = %#v, want retained-record scope and reason", warning)
|
t.Fatalf("duplicate finding = %#v, want retained-record scope and reason", finding)
|
||||||
}
|
}
|
||||||
if !strings.Contains(warning.Message, "retained input index 7") || !strings.Contains(warning.Message, fmt.Sprintf("removed input index %d", removed[0])) {
|
if !strings.Contains(finding.Message, "retained input index 7") || !strings.Contains(finding.Message, fmt.Sprintf("removed input index %d", removed[0])) {
|
||||||
t.Fatalf("duplicate warning = %q, want retained and displayed removed indexes", warning.Message)
|
t.Fatalf("duplicate finding = %q, want retained and displayed removed indexes", finding.Message)
|
||||||
}
|
}
|
||||||
if !strings.Contains(warning.Message, "5 additional issue(s) omitted") {
|
if !strings.Contains(finding.Message, "5 additional issue(s) omitted") {
|
||||||
t.Fatalf("duplicate warning = %q, want exact omitted count", warning.Message)
|
t.Fatalf("duplicate finding = %q, want exact omitted count", finding.Message)
|
||||||
}
|
}
|
||||||
if !utf8.ValidString(warning.Message) || len([]byte(warning.Message)) > 4096 {
|
if !utf8.ValidString(finding.Message) || len([]byte(finding.Message)) > 4096 {
|
||||||
t.Fatalf("duplicate warning length/encoding = %d/%t", len([]byte(warning.Message)), utf8.ValidString(warning.Message))
|
t.Fatalf("duplicate finding length/encoding = %d/%t", len([]byte(finding.Message)), utf8.ValidString(finding.Message))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,8 +104,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
order := shared.NewSourceRefOrderFromIndex(index)
|
order := shared.NewSourceRefOrderFromIndex(index)
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, order, registry)
|
value, findings := normalizeList(req.MergeOutput.Value, order, registry)
|
||||||
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings)
|
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
return contracts.TypedNormalizeResult[dnd.EnemyEventList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -128,12 +128,12 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist
|
|||||||
return dnd.EnemyEventList{}, nil
|
return dnd.EnemyEventList{}, nil
|
||||||
}
|
}
|
||||||
records := make([]normalizedRecord, len(input.Events))
|
records := make([]normalizedRecord, len(input.Events))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, inputEvent := range input.Events {
|
for index, inputEvent := range input.Events {
|
||||||
event, nameChange, refsChanged := normalizeEvent(inputEvent, order, registry)
|
event, nameChange, refsChanged := normalizeEvent(inputEvent, order, registry)
|
||||||
records[index] = normalizedRecord{event: event, identity: enemyeventmodel.CanonicalIdentity(event), inputIndex: index}
|
records[index] = normalizedRecord{event: event, identity: enemyeventmodel.CanonicalIdentity(event), inputIndex: index}
|
||||||
if nameChange != nil {
|
if nameChange != nil {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: eventScope(index),
|
Scope: eventScope(index),
|
||||||
ReasonCode: ReasonCodeNameCanonicalized,
|
ReasonCode: ReasonCodeNameCanonicalized,
|
||||||
Message: fmt.Sprintf("input index %d: subject canonicalized from %s to %s",
|
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 {
|
if refsChanged {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: eventScope(index),
|
Scope: eventScope(index),
|
||||||
ReasonCode: ReasonCodeSourceRefsNormalized,
|
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
||||||
@@ -157,16 +157,16 @@ func normalizeList(input dnd.EnemyEventList, order shared.SourceRefOrder, regist
|
|||||||
if position == record.inputIndex {
|
if position == record.inputIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: eventScope(record.inputIndex),
|
Scope: eventScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeEventsReordered,
|
ReasonCode: ReasonCodeEventsReordered,
|
||||||
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records)
|
output, duplicateFindings := collapseDuplicates(records)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
findings = append(findings, duplicateFindings...)
|
||||||
return dnd.EnemyEventList{Events: output}, warnings
|
return dnd.EnemyEventList{Events: output}, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeEvent(input dnd.EnemyEvent, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEvent, *nameCanonicalization, bool) {
|
func normalizeEvent(input dnd.EnemyEvent, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.EnemyEvent, *nameCanonicalization, bool) {
|
||||||
@@ -235,7 +235,7 @@ func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []diagnos
|
|||||||
for index, record := range kept {
|
for index, record := range kept {
|
||||||
output[index] = cloneEvent(record.event)
|
output[index] = cloneEvent(record.event)
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if len(group.removed) == 0 {
|
if len(group.removed) == 0 {
|
||||||
continue
|
continue
|
||||||
@@ -244,14 +244,14 @@ func collapseDuplicates(records []normalizedRecord) ([]dnd.EnemyEvent, []diagnos
|
|||||||
for index, removed := range group.removed {
|
for index, removed := range group.removed {
|
||||||
issues[index] = fmt.Sprintf("removed input index %d", removed)
|
issues[index] = fmt.Sprintf("removed input index %d", removed)
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: eventScope(group.retainedIndex),
|
Scope: eventScope(group.retainedIndex),
|
||||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||||
Message: diagnostics.Aggregate(
|
Message: diagnostics.Aggregate(
|
||||||
fmt.Sprintf("duplicate enemy event collapsed; retained input index %d", group.retainedIndex), issues),
|
fmt.Sprintf("duplicate enemy event collapsed; retained input index %d", group.retainedIndex), issues),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) }
|
func eventScope(index int) string { return fmt.Sprintf("events[%d]", index) }
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func TestNormalizeRequiresRegistryAndKeepsOperationContentOutOfMetadata(t *testi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizerContractAndWarningBound(t *testing.T) {
|
func TestNormalizerContractAndFindingBound(t *testing.T) {
|
||||||
normalizer := newNormalizer(t, npcReferences(t))
|
normalizer := newNormalizer(t, npcReferences(t))
|
||||||
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil {
|
||||||
t.Fatal("DecodeOptions() accepted an unknown option")
|
t.Fatal("DecodeOptions() accepted an unknown option")
|
||||||
|
|||||||
@@ -106,8 +106,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("item registry reference is required")
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("item registry reference is required")
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry)
|
value, findings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry)
|
||||||
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings, ReasonCodeUnknownItemID)
|
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings, ReasonCodeUnknownItemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
return contracts.TypedNormalizeResult[dnd.ItemOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -126,12 +126,12 @@ func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, ord
|
|||||||
}
|
}
|
||||||
|
|
||||||
records := make([]normalizedRecord, len(input.Occurrences))
|
records := make([]normalizedRecord, len(input.Occurrences))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, inputOccurrence := range input.Occurrences {
|
for index, inputOccurrence := range input.Occurrences {
|
||||||
occurrence, changedFields, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry)
|
occurrence, changedFields, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry)
|
||||||
records[index] = normalizedRecord{occurrence: occurrence, identity: itemoccurrencemodel.CanonicalExactIdentity(occurrence), inputIndex: index}
|
records[index] = normalizedRecord{occurrence: occurrence, identity: itemoccurrencemodel.CanonicalExactIdentity(occurrence), inputIndex: index}
|
||||||
if len(changedFields) != 0 {
|
if len(changedFields) != 0 {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: occurrenceScope(index),
|
Scope: occurrenceScope(index),
|
||||||
ReasonCode: ReasonCodeNameCanonicalized,
|
ReasonCode: ReasonCodeNameCanonicalized,
|
||||||
Message: fmt.Sprintf("input index %d: normalized display whitespace in %s", index,
|
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 {
|
if found && inputOccurrence.Name != occurrence.Name {
|
||||||
warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized,
|
findings = append(findings, 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))})
|
Message: fmt.Sprintf("input index %d: item name canonicalized from %s to %s", index, diagnostics.Quote(inputOccurrence.Name), diagnostics.Quote(occurrence.Name))})
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownItemID,
|
findings = append(findings, 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))})
|
Message: fmt.Sprintf("input index %d: item ID %s is not in the supplied registry", index, diagnostics.Quote(inputOccurrence.ItemID))})
|
||||||
}
|
}
|
||||||
if refsChanged {
|
if refsChanged {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: occurrenceScope(index),
|
Scope: occurrenceScope(index),
|
||||||
ReasonCode: ReasonCodeSourceRefsNormalized,
|
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
||||||
@@ -163,16 +163,16 @@ func normalizeList(input dnd.ItemOccurrenceList, index source.DocumentIndex, ord
|
|||||||
if position == record.inputIndex {
|
if position == record.inputIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: occurrenceScope(record.inputIndex),
|
Scope: occurrenceScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeOccurrencesReordered,
|
ReasonCode: ReasonCodeOccurrencesReordered,
|
||||||
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
Message: fmt.Sprintf("input index %d moved to normalized position %d", record.inputIndex, position),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records, index)
|
output, duplicateFindings := collapseDuplicates(records, index)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
findings = append(findings, duplicateFindings...)
|
||||||
return dnd.ItemOccurrenceList{Occurrences: output}, warnings
|
return dnd.ItemOccurrenceList{Occurrences: output}, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeOccurrence(input dnd.ItemOccurrence, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrence, []string, bool, bool) {
|
func normalizeOccurrence(input dnd.ItemOccurrence, order shared.SourceRefOrder, registry *itemregistry.Registry) (dnd.ItemOccurrence, []string, bool, bool) {
|
||||||
@@ -253,16 +253,16 @@ func collapseDuplicates(records []normalizedRecord, index source.DocumentIndex)
|
|||||||
output = append(output, cloneOccurrence(record.occurrence))
|
output = append(output, cloneOccurrence(record.occurrence))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if len(group.removed) != 0 {
|
if len(group.removed) != 0 {
|
||||||
warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed))
|
findings = append(findings, duplicateFinding(group.retainedIndex, group.removed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
issues := make([]string, len(removed))
|
issues := make([]string, len(removed))
|
||||||
for index, removedIndex := range removed {
|
for index, removedIndex := range removed {
|
||||||
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ func preprocessRecords(input dnd.ItemRegistry, order shared.SourceRefOrder) ([]n
|
|||||||
retained.inputIndexes = sortedUniqueIndexes(retained.inputIndexes)
|
retained.inputIndexes = sortedUniqueIndexes(retained.inputIndexes)
|
||||||
output = append(output, retained)
|
output = append(output, retained)
|
||||||
if len(members) > 1 {
|
if len(members) > 1 {
|
||||||
findings = append(findings, duplicateWarning(retained.earliest, memberInputIndexes(records, members[1:])))
|
findings = append(findings, duplicateFinding(retained.earliest, memberInputIndexes(records, members[1:])))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output, findings
|
return output, findings
|
||||||
@@ -328,7 +328,7 @@ func recordList(records []normalizedRecord) dnd.ItemRegistry {
|
|||||||
return dnd.ItemRegistry{Items: recordValues(records)}
|
return dnd.ItemRegistry{Items: recordValues(records)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
const maxDisplayedIndices = 20
|
const maxDisplayedIndices = 20
|
||||||
displayed := removed
|
displayed := removed
|
||||||
if len(displayed) > maxDisplayedIndices {
|
if len(displayed) > maxDisplayedIndices {
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ func TestNormalizeLimitSkipDoesNotCallLLMAndAddsBoundedFallbackWarning(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeRetryFallbackErrorsWarningsAndIdempotence(t *testing.T) {
|
func TestNormalizeRetryFallbackErrorsAndIdempotence(t *testing.T) {
|
||||||
doc := semanticDocument()
|
doc := semanticDocument()
|
||||||
input := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}}
|
input := dnd.ItemRegistry{Items: []dnd.Item{{Name: "Star Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}}, {Name: "Compass", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}}}}}
|
||||||
invalid, err := newNormalizer(t, &recordingNormalizerClient{err: contracts.ErrInvalidStructuredOutput}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
invalid, err := newNormalizer(t, &recordingNormalizerClient{err: contracts.ErrInvalidStructuredOutput}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
return contracts.TypedNormalizeResult[dnd.LocationOccurrenceList]{}, normalizerErrorf("location registry reference is required")
|
return contracts.TypedNormalizeResult[dnd.LocationOccurrenceList]{}, normalizerErrorf("location registry reference is required")
|
||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry)
|
value, findings := normalizeList(req.MergeOutput.Value, index, shared.NewSourceRefOrderFromIndex(index), registry)
|
||||||
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings, ReasonCodeUnknownLocationID)
|
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings, ReasonCodeUnknownLocationID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.LocationOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
return contracts.TypedNormalizeResult[dnd.LocationOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -127,20 +127,20 @@ func normalizeList(input dnd.LocationOccurrenceList, documentIndex source.Docume
|
|||||||
return dnd.LocationOccurrenceList{}, nil
|
return dnd.LocationOccurrenceList{}, nil
|
||||||
}
|
}
|
||||||
records := make([]normalizedRecord, len(input.Occurrences))
|
records := make([]normalizedRecord, len(input.Occurrences))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, inputOccurrence := range input.Occurrences {
|
for index, inputOccurrence := range input.Occurrences {
|
||||||
occurrence, change, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry)
|
occurrence, change, found, refsChanged := normalizeOccurrence(inputOccurrence, order, registry)
|
||||||
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
|
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
|
||||||
if change != nil {
|
if change != nil {
|
||||||
warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeNameCanonicalized,
|
findings = append(findings, 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))})
|
Message: fmt.Sprintf("input index %d: location name canonicalized from %s to %s", index, diagnostics.Quote(change.from), diagnostics.Quote(change.to))})
|
||||||
}
|
}
|
||||||
if !found {
|
if !found {
|
||||||
warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeUnknownLocationID,
|
findings = append(findings, 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))})
|
Message: fmt.Sprintf("input index %d: location ID %s is not in the supplied registry", index, diagnostics.Quote(inputOccurrence.LocationID))})
|
||||||
}
|
}
|
||||||
if refsChanged {
|
if refsChanged {
|
||||||
warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(index), ReasonCode: ReasonCodeSourceRefsNormalized,
|
findings = append(findings, 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))})
|
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)", index, len(inputOccurrence.SourceRefs), len(occurrence.SourceRefs))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,13 +149,13 @@ func normalizeList(input dnd.LocationOccurrenceList, documentIndex source.Docume
|
|||||||
})
|
})
|
||||||
for position, record := range records {
|
for position, record := range records {
|
||||||
if position != record.inputIndex {
|
if position != record.inputIndex {
|
||||||
warnings = append(warnings, diagnostics.Finding{Scope: occurrenceScope(record.inputIndex), ReasonCode: ReasonCodeOccurrencesReordered,
|
findings = append(findings, 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)})
|
Message: fmt.Sprintf("input index %d moved to normalized position %d by canonical occurrence order", record.inputIndex, position)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
|
output, duplicateFindings := collapseDuplicates(records, documentIndex)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
findings = append(findings, duplicateFindings...)
|
||||||
return dnd.LocationOccurrenceList{Occurrences: output}, warnings
|
return dnd.LocationOccurrenceList{Occurrences: output}, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeOccurrence(input dnd.LocationOccurrence, order shared.SourceRefOrder, registry *locationregistry.Registry) (dnd.LocationOccurrence, *nameCanonicalization, bool, bool) {
|
func normalizeOccurrence(input dnd.LocationOccurrence, order shared.SourceRefOrder, registry *locationregistry.Registry) (dnd.LocationOccurrence, *nameCanonicalization, bool, bool) {
|
||||||
@@ -222,13 +222,13 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen
|
|||||||
output = append(output, cloneOccurrence(record.occurrence))
|
output = append(output, cloneOccurrence(record.occurrence))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if len(group.removed) > 0 {
|
if len(group.removed) > 0 {
|
||||||
warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed))
|
findings = append(findings, duplicateFinding(group.retainedIndex, group.removed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func validSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
func validSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
||||||
@@ -316,7 +316,7 @@ func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef)
|
|||||||
return len(left) < len(right)
|
return len(left) < len(right)
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
issues := make([]string, len(removed))
|
issues := make([]string, len(removed))
|
||||||
for index, removedIndex := range removed {
|
for index, removedIndex := range removed {
|
||||||
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func TestNormalizePreservesUnknownIDsAndMalformedOperationRegistry(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizerContractsRequiredRegistryAndWarningBounds(t *testing.T) {
|
func TestNormalizerContractsRequiredRegistryAndFindingBounds(t *testing.T) {
|
||||||
if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
|
if _, err := New(Options{}, contracts.ReferenceSet{}, contracts.ReferenceSet{}); err == nil || !strings.Contains(err.Error(), "at most one reference set") {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,18 +209,18 @@ func preprocessRecords(input dnd.LocationRegistry, order shared.SourceRefOrder)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
records := make([]normalizedRecord, len(input.Locations))
|
records := make([]normalizedRecord, len(input.Locations))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, inputLocation := range input.Locations {
|
for index, inputLocation := range input.Locations {
|
||||||
location, fieldsChanged, refsChanged := normalizeRecord(inputLocation, order)
|
location, fieldsChanged, refsChanged := normalizeRecord(inputLocation, order)
|
||||||
records[index] = normalizedRecord{location: location, inputIndexes: []int{index}, earliest: index}
|
records[index] = normalizedRecord{location: location, inputIndexes: []int{index}, earliest: index}
|
||||||
if fieldsChanged {
|
if fieldsChanged {
|
||||||
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))})
|
findings = append(findings, 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 {
|
if refsChanged {
|
||||||
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))})
|
findings = append(findings, 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 {
|
if inputLocation.ID != location.ID {
|
||||||
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))})
|
findings = append(findings, 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)
|
groups := exactDuplicateGroups(records)
|
||||||
@@ -233,10 +233,10 @@ func preprocessRecords(input dnd.LocationRegistry, order shared.SourceRefOrder)
|
|||||||
retained.inputIndexes = sortedUniqueIndexes(retained.inputIndexes)
|
retained.inputIndexes = sortedUniqueIndexes(retained.inputIndexes)
|
||||||
output = append(output, retained)
|
output = append(output, retained)
|
||||||
if len(members) > 1 {
|
if len(members) > 1 {
|
||||||
warnings = append(warnings, duplicateWarning(retained.earliest, memberInputIndexes(records, members[1:])))
|
findings = append(findings, duplicateFinding(retained.earliest, memberInputIndexes(records, members[1:])))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRecord(input dnd.Location, order shared.SourceRefOrder) (dnd.Location, bool, bool) {
|
func normalizeRecord(input dnd.Location, order shared.SourceRefOrder) (dnd.Location, bool, bool) {
|
||||||
@@ -342,7 +342,7 @@ func recordList(records []normalizedRecord) dnd.LocationRegistry {
|
|||||||
return dnd.LocationRegistry{Locations: recordValues(records)}
|
return dnd.LocationRegistry{Locations: recordValues(records)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
const maxDisplayedIndices = 20
|
const maxDisplayedIndices = 20
|
||||||
displayed := removed
|
displayed := removed
|
||||||
if len(displayed) > maxDisplayedIndices {
|
if len(displayed) > maxDisplayedIndices {
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t
|
|||||||
t.Fatalf("locations = %#v, want same names and nested place retained", got)
|
t.Fatalf("locations = %#v, want same names and nested place retained", got)
|
||||||
}
|
}
|
||||||
if result.Value.Locations[0].ID == result.Value.Locations[1].ID || !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateLocationCollapsed, contracts.DiagnosticDispositionObservation) || len(client.requests) != 0 {
|
if result.Value.Locations[0].ID == result.Value.Locations[1].ID || !hasDiagnostic(result.Diagnostics, ReasonCodeDuplicateLocationCollapsed, contracts.DiagnosticDispositionObservation) || len(client.requests) != 0 {
|
||||||
t.Fatalf("result = %#v, want evidence-anchored IDs and exact duplicate warning", result)
|
t.Fatalf("result = %#v, want evidence-anchored IDs and exact duplicate finding", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,15 +54,15 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe
|
|||||||
earliest: record.EarliestInputPosition(),
|
earliest: record.EarliestInputPosition(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0, len(application.AppliedGroups()))
|
findings := make([]diagnostics.Finding, 0, len(application.AppliedGroups()))
|
||||||
for _, event := range application.AppliedGroups() {
|
for _, event := range application.AppliedGroups() {
|
||||||
provenance := event.Provenance()
|
provenance := event.Provenance()
|
||||||
warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()]))
|
findings = append(findings, semanticDuplicateFinding(provenance, records[provenance.CanonicalPosition()]))
|
||||||
}
|
}
|
||||||
return output, warnings, nil
|
return output, findings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding {
|
func semanticDuplicateFinding(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding {
|
||||||
inputIndexes := provenance.OriginalInputIndexes()
|
inputIndexes := provenance.OriginalInputIndexes()
|
||||||
details := make([]string, 0, len(inputIndexes)+1)
|
details := make([]string, 0, len(inputIndexes)+1)
|
||||||
for _, inputIndex := range inputIndexes {
|
for _, inputIndex := range inputIndexes {
|
||||||
|
|||||||
@@ -108,11 +108,11 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
}
|
}
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
order := shared.NewSourceRefOrderFromIndex(index)
|
order := shared.NewSourceRefOrderFromIndex(index)
|
||||||
value, warnings, err := normalizeList(req.MergeOutput.Value, index, order, registry)
|
value, findings, err := normalizeList(req.MergeOutput.Value, index, order, registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("validate NPC registry pairs: %w", err)
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("validate NPC registry pairs: %w", err)
|
||||||
}
|
}
|
||||||
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings)
|
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
return contracts.TypedNormalizeResult[dnd.NPCOccurrenceList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -130,7 +130,7 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd
|
|||||||
}
|
}
|
||||||
|
|
||||||
records := make([]normalizedRecord, len(input.Occurrences))
|
records := make([]normalizedRecord, len(input.Occurrences))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, inputOccurrence := range input.Occurrences {
|
for index, inputOccurrence := range input.Occurrences {
|
||||||
occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry)
|
occurrence, refsChanged, err := normalizeOccurrence(inputOccurrence, order, registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -138,7 +138,7 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd
|
|||||||
}
|
}
|
||||||
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
|
records[index] = normalizedRecord{occurrence: occurrence, inputIndex: index}
|
||||||
if refsChanged {
|
if refsChanged {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: occurrenceScope(index),
|
Scope: occurrenceScope(index),
|
||||||
ReasonCode: ReasonCodeSourceRefsNormalized,
|
ReasonCode: ReasonCodeSourceRefsNormalized,
|
||||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d)",
|
||||||
@@ -154,16 +154,16 @@ func normalizeList(input dnd.NPCOccurrenceList, documentIndex source.DocumentInd
|
|||||||
if position == record.inputIndex {
|
if position == record.inputIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: occurrenceScope(record.inputIndex),
|
Scope: occurrenceScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeOccurrencesReordered,
|
ReasonCode: ReasonCodeOccurrencesReordered,
|
||||||
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position),
|
Message: fmt.Sprintf("input index %d moved to normalized position %d by source chronology", record.inputIndex, position),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
|
output, duplicateFindings := collapseDuplicates(records, documentIndex)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
findings = append(findings, duplicateFindings...)
|
||||||
return dnd.NPCOccurrenceList{Occurrences: output}, warnings, nil
|
return dnd.NPCOccurrenceList{Occurrences: output}, findings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) {
|
func normalizeOccurrence(input dnd.NPCOccurrence, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCOccurrence, bool, error) {
|
||||||
@@ -220,16 +220,16 @@ func collapseDuplicates(records []normalizedRecord, documentIndex source.Documen
|
|||||||
output = append(output, cloneOccurrence(record.occurrence))
|
output = append(output, cloneOccurrence(record.occurrence))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if len(group.removed) != 0 {
|
if len(group.removed) != 0 {
|
||||||
warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed))
|
findings = append(findings, duplicateFinding(group.retainedIndex, group.removed))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
issues := make([]string, len(removed))
|
issues := make([]string, len(removed))
|
||||||
for index, removedIndex := range removed {
|
for index, removedIndex := range removed {
|
||||||
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
issues[index] = fmt.Sprintf("removed input index %d", removedIndex)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestNormalizeValidatesPairsAndClones(t *testing.T) {
|
|||||||
}
|
}
|
||||||
second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: result.Value}})
|
second, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.NPCOccurrenceList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.NPCOccurrenceList]{Value: result.Value}})
|
||||||
if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Diagnostics) != 0 {
|
if err != nil || !reflect.DeepEqual(second.Value, result.Value) || len(second.Diagnostics) != 0 {
|
||||||
t.Fatalf("second normalization = %#v, %v; want idempotent output without warnings", second, err)
|
t.Fatalf("second normalization = %#v, %v; want idempotent output without findings", second, err)
|
||||||
}
|
}
|
||||||
result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
result.Value.Occurrences[0].SourceRefs[0].StartUnitID = 999
|
||||||
if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 {
|
if input.Occurrences[0].SourceRefs[0].StartUnitID == 999 {
|
||||||
@@ -120,7 +120,7 @@ func TestNormalizeOrdersAndCollapsesExactDuplicatesOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
|
func TestNormalizerContractAndDeterministicFindings(t *testing.T) {
|
||||||
normalizer, err := New(Options{}, npcReferences(t))
|
normalizer, err := New(Options{}, npcReferences(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -139,7 +139,7 @@ func TestNormalizerContractAndDeterministicWarnings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeBoundsWarnings(t *testing.T) {
|
func TestNormalizeBoundsFindings(t *testing.T) {
|
||||||
count := contracts.MaxDiagnosticSamples + 5
|
count := contracts.MaxDiagnosticSamples + 5
|
||||||
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
||||||
input := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, count)}
|
input := dnd.NPCOccurrenceList{Occurrences: make([]dnd.NPCOccurrence, count)}
|
||||||
|
|||||||
@@ -208,18 +208,18 @@ func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]no
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
records := make([]normalizedRecord, len(input.NPCs))
|
records := make([]normalizedRecord, len(input.NPCs))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, inputNPC := range input.NPCs {
|
for index, inputNPC := range input.NPCs {
|
||||||
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
|
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
|
||||||
records[index] = normalizedRecord{npc: npc, inputIndexes: []int{index}, earliest: index}
|
records[index] = normalizedRecord{npc: npc, inputIndexes: []int{index}, earliest: index}
|
||||||
if fieldsChanged {
|
if fieldsChanged {
|
||||||
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))})
|
findings = append(findings, 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 {
|
if referencesChanged {
|
||||||
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))})
|
findings = append(findings, 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 {
|
if inputNPC.ID != npc.ID {
|
||||||
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))})
|
findings = append(findings, diagnostics.Finding{Scope: npcScope(index), ReasonCode: ReasonCodeNPCIDRecomputed, Message: fmt.Sprintf("input index %d: NPC ID recomputed from %s", index, diagnostics.Quote(npc.Name))})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,13 +230,13 @@ func preprocessRecords(input dnd.NPCRegistry, order shared.SourceRefOrder) ([]no
|
|||||||
output = append(output, consolidated)
|
output = append(output, consolidated)
|
||||||
retainedIndex := consolidated.earliest
|
retainedIndex := consolidated.earliest
|
||||||
if referencesChanged {
|
if referencesChanged {
|
||||||
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))})
|
findings = append(findings, 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 {
|
if len(members) > 1 {
|
||||||
warnings = append(warnings, duplicateWarning(retainedIndex, memberInputIndexes(records, members[1:])))
|
findings = append(findings, duplicateFinding(retainedIndex, memberInputIndexes(records, members[1:])))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) {
|
func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) {
|
||||||
@@ -340,7 +340,7 @@ func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
|
|||||||
return append([]source.SourceRef(nil), input...)
|
return append([]source.SourceRef(nil), input...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
const maxDisplayedIndices = 20
|
const maxDisplayedIndices = 20
|
||||||
displayed := removed
|
displayed := removed
|
||||||
if len(displayed) > maxDisplayedIndices {
|
if len(displayed) > maxDisplayedIndices {
|
||||||
|
|||||||
@@ -54,15 +54,15 @@ func applyReconciliationPlan(plan semanticreconcile.Plan, records []normalizedRe
|
|||||||
earliest: record.EarliestInputPosition(),
|
earliest: record.EarliestInputPosition(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0, len(application.AppliedGroups()))
|
findings := make([]diagnostics.Finding, 0, len(application.AppliedGroups()))
|
||||||
for _, event := range application.AppliedGroups() {
|
for _, event := range application.AppliedGroups() {
|
||||||
provenance := event.Provenance()
|
provenance := event.Provenance()
|
||||||
warnings = append(warnings, semanticDuplicateWarning(provenance, records[provenance.CanonicalPosition()]))
|
findings = append(findings, semanticDuplicateFinding(provenance, records[provenance.CanonicalPosition()]))
|
||||||
}
|
}
|
||||||
return output, warnings, nil
|
return output, findings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func semanticDuplicateWarning(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding {
|
func semanticDuplicateFinding(provenance semanticreconcile.GroupProvenance, canonical normalizedRecord) diagnostics.Finding {
|
||||||
inputIndexes := provenance.OriginalInputIndexes()
|
inputIndexes := provenance.OriginalInputIndexes()
|
||||||
details := make([]string, 0, len(inputIndexes)+1)
|
details := make([]string, 0, len(inputIndexes)+1)
|
||||||
for _, inputIndex := range inputIndexes {
|
for _, inputIndex := range inputIndexes {
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context error before normalize: %w", err)
|
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||||
}
|
}
|
||||||
value, warnings, err := normalizeList(req.MergeOutput.Value, req.Source)
|
value, findings, err := normalizeList(req.MergeOutput.Value, req.Source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalize scenes: %w", err)
|
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("normalize scenes: %w", err)
|
||||||
}
|
}
|
||||||
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings)
|
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
return contracts.TypedNormalizeResult[dnd.SceneDescriptionList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -92,7 +92,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
|
|||||||
|
|
||||||
documentIndex := source.NewDocumentIndex(doc)
|
documentIndex := source.NewDocumentIndex(doc)
|
||||||
records := make([]normalizedScene, len(input.Scenes))
|
records := make([]normalizedScene, len(input.Scenes))
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for sceneIndex, scene := range input.Scenes {
|
for sceneIndex, scene := range input.Scenes {
|
||||||
originalTitle, originalSummary := scene.Title, scene.Summary
|
originalTitle, originalSummary := scene.Title, scene.Summary
|
||||||
scene.Title = strings.TrimSpace(scene.Title)
|
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()))
|
return dnd.SceneDescriptionList{}, nil, fmt.Errorf("scenes[%d].source_ref: %s", sceneIndex, diagnostics.Truncate(err.Error()))
|
||||||
}
|
}
|
||||||
if originalTitle != scene.Title || originalSummary != scene.Summary {
|
if originalTitle != scene.Title || originalSummary != scene.Summary {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: sceneScope(sceneIndex),
|
Scope: sceneScope(sceneIndex),
|
||||||
ReasonCode: ReasonCodeProseNormalized,
|
ReasonCode: ReasonCodeProseNormalized,
|
||||||
Message: fmt.Sprintf("input index %d: title and/or summary whitespace normalized", sceneIndex),
|
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 {
|
if position == record.inputIndex {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: sceneScope(record.inputIndex),
|
Scope: sceneScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeOrderNormalized,
|
ReasonCode: ReasonCodeOrderNormalized,
|
||||||
Message: fmt.Sprintf("input index %d moved to normalized position %d by canonical scene order",
|
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))
|
return dnd.SceneDescriptionList{}, nil, fmt.Errorf("source range %s has conflicting records", sourceRefLabel(scene.SourceRef))
|
||||||
}
|
}
|
||||||
if retainedIndex, ok := seen[scene]; ok {
|
if retainedIndex, ok := seen[scene]; ok {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: sceneScope(record.inputIndex),
|
Scope: sceneScope(record.inputIndex),
|
||||||
ReasonCode: ReasonCodeDuplicateCollapsed,
|
ReasonCode: ReasonCodeDuplicateCollapsed,
|
||||||
Message: fmt.Sprintf("input index %d: exact duplicate scene collapsed; retained input index %d",
|
Message: fmt.Sprintf("input index %d: exact duplicate scene collapsed; retained input index %d",
|
||||||
@@ -159,7 +159,7 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
|
|||||||
seen[scene] = record.inputIndex
|
seen[scene] = record.inputIndex
|
||||||
unique = append(unique, scene)
|
unique = append(unique, scene)
|
||||||
}
|
}
|
||||||
return dnd.SceneDescriptionList{Scenes: unique}, warnings, nil
|
return dnd.SceneDescriptionList{Scenes: unique}, findings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sceneScope(index int) string { return fmt.Sprintf("scenes[%d]", index) }
|
func sceneScope(index int) string { return fmt.Sprintf("scenes[%d]", index) }
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ func TestNormalizeTrimsOrdersDeduplicatesAndOwnsOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeLimitsCombinedSceneMutationWarnings(t *testing.T) {
|
func TestNormalizeLimitsCombinedSceneMutationFindings(t *testing.T) {
|
||||||
count := contracts.MaxDiagnosticSamples + 1
|
count := contracts.MaxDiagnosticSamples + 1
|
||||||
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
doc := &source.SourceDocument{ID: "session", Units: make([]source.SourceUnit, count)}
|
||||||
input := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, count)}
|
input := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, count)}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ type normalizerFixtureSet struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type normalizerFixtureCase struct {
|
type normalizerFixtureCase struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Input dnd.SpellList `json:"input"`
|
Input dnd.SpellList `json:"input"`
|
||||||
Output dnd.SpellList `json:"output"`
|
Output dnd.SpellList `json:"output"`
|
||||||
WarningReasonCodes []string `json:"warning_reason_codes"`
|
DiagnosticReasonCodes []string `json:"diagnostic_reason_codes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeAcceptedFixtures(t *testing.T) {
|
func TestNormalizeAcceptedFixtures(t *testing.T) {
|
||||||
@@ -48,8 +48,8 @@ func TestNormalizeAcceptedFixtures(t *testing.T) {
|
|||||||
for _, diagnostic := range result.Diagnostics {
|
for _, diagnostic := range result.Diagnostics {
|
||||||
gotReasonCodes = append(gotReasonCodes, diagnostic.ReasonCode)
|
gotReasonCodes = append(gotReasonCodes, diagnostic.ReasonCode)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(gotReasonCodes, fixture.WarningReasonCodes) {
|
if !reflect.DeepEqual(gotReasonCodes, fixture.DiagnosticReasonCodes) {
|
||||||
t.Fatalf("warning reason codes = %#v, want %#v", gotReasonCodes, fixture.WarningReasonCodes)
|
t.Fatalf("diagnostic reason codes = %#v, want %#v", gotReasonCodes, fixture.DiagnosticReasonCodes)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,10 +100,10 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
|
|
||||||
index := source.NewDocumentIndex(req.Source)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
order := shared.NewSourceRefOrderFromIndex(index)
|
order := shared.NewSourceRefOrderFromIndex(index)
|
||||||
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order)
|
value, findings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order)
|
||||||
value, duplicateWarnings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog)
|
value, duplicateFindings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
findings = append(findings, duplicateFindings...)
|
||||||
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(warnings, ReasonCodeSpellNameUnresolved)
|
diagnosticGroups, err := diagnostics.NormalizationDiagnostics(findings, ReasonCodeSpellNameUnresolved)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("collect diagnostics: %w", err)
|
||||||
}
|
}
|
||||||
@@ -111,7 +111,7 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []diagnostics.Finding) {
|
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []diagnostics.Finding) {
|
||||||
var warnings []diagnostics.Finding
|
var findings []diagnostics.Finding
|
||||||
if input.SpellCasts == nil {
|
if input.SpellCasts == nil {
|
||||||
return dnd.SpellList{}, nil
|
return dnd.SpellList{}, nil
|
||||||
}
|
}
|
||||||
@@ -121,7 +121,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal
|
|||||||
cast := cloneSpellCast(inputCast)
|
cast := cloneSpellCast(inputCast)
|
||||||
if canonicalName, ok := catalog.Lookup(inputCast.Spell); ok {
|
if canonicalName, ok := catalog.Lookup(inputCast.Spell); ok {
|
||||||
if inputCast.Spell != canonicalName {
|
if inputCast.Spell != canonicalName {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: spellCastScope(index),
|
Scope: spellCastScope(index),
|
||||||
ReasonCode: ReasonCodeSpellNameCanonicalized,
|
ReasonCode: ReasonCodeSpellNameCanonicalized,
|
||||||
Message: fmt.Sprintf("input index %d: spell name canonicalized from %q to %q",
|
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
|
cast.Spell = canonicalName
|
||||||
} else {
|
} else {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: spellCastScope(index),
|
Scope: spellCastScope(index),
|
||||||
ReasonCode: ReasonCodeSpellNameUnresolved,
|
ReasonCode: ReasonCodeSpellNameUnresolved,
|
||||||
Message: fmt.Sprintf("input index %d: spell name %q could not be resolved in the effective catalog",
|
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)
|
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(order, inputCast.SourceRefs)
|
||||||
cast.SourceRefs = canonicalRefs
|
cast.SourceRefs = canonicalRefs
|
||||||
if orderChanged || duplicateCount > 0 {
|
if orderChanged || duplicateCount > 0 {
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: spellCastScope(index),
|
Scope: spellCastScope(index),
|
||||||
ReasonCode: ReasonCodeSourceReferencesNormalized,
|
ReasonCode: ReasonCodeSourceReferencesNormalized,
|
||||||
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d, order changed %t, duplicates removed %d)",
|
Message: fmt.Sprintf("input index %d: source references normalized (original count %d, final count %d, order changed %t, duplicates removed %d)",
|
||||||
@@ -150,7 +150,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal
|
|||||||
}
|
}
|
||||||
output.SpellCasts[index] = cast
|
output.SpellCasts[index] = cast
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
|
func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
|
||||||
@@ -221,14 +221,14 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.Docum
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
if len(group.removed) == 0 {
|
if len(group.removed) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, duplicateWarning(group.retainedIndex, group.removed))
|
findings = append(findings, duplicateFinding(group.retainedIndex, group.removed))
|
||||||
}
|
}
|
||||||
return output, warnings
|
return output, findings
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateKey(cast dnd.SpellCast, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (string, bool) {
|
func duplicateKey(cast dnd.SpellCast, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (string, bool) {
|
||||||
@@ -264,7 +264,7 @@ func writeKeyInt(builder *strings.Builder, value int) {
|
|||||||
builder.WriteByte(';')
|
builder.WriteByte(';')
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateWarning(retainedIndex int, removed []int) diagnostics.Finding {
|
func duplicateFinding(retainedIndex int, removed []int) diagnostics.Finding {
|
||||||
const maxDisplayedIndices = 20
|
const maxDisplayedIndices = 20
|
||||||
displayed := removed
|
displayed := removed
|
||||||
if len(displayed) > maxDisplayedIndices {
|
if len(displayed) > maxDisplayedIndices {
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ func TestNormalizeCanonicalizesNamesAndReportsUnresolvedNames(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeLimitsWarningsWithoutChangingSpellValues(t *testing.T) {
|
func TestNormalizeLimitsFindingsWithoutChangingSpellValues(t *testing.T) {
|
||||||
input := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, contracts.MaxDiagnosticSamples+1)}
|
input := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, contracts.MaxDiagnosticSamples+1)}
|
||||||
for index := range input.SpellCasts {
|
for index := range input.SpellCasts {
|
||||||
input.SpellCasts[index].Spell = fmt.Sprintf("Unknown Spell %d", index)
|
input.SpellCasts[index].Spell = fmt.Sprintf("Unknown Spell %d", index)
|
||||||
@@ -241,7 +241,7 @@ func TestNormalizeReportsDuplicateRemovalWithoutOrderChange(t *testing.T) {
|
|||||||
}
|
}
|
||||||
message := diagnostic.Samples[0].Message
|
message := diagnostic.Samples[0].Message
|
||||||
if !strings.Contains(message, "order changed false") || !strings.Contains(message, "duplicates removed 1") {
|
if !strings.Contains(message, "order changed false") || !strings.Contains(message, "duplicates removed 1") {
|
||||||
t.Fatalf("warning = %q, want duplicate-only repair without order change", message)
|
t.Fatalf("finding = %q, want duplicate-only repair without order change", message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,7 +406,7 @@ func TestNormalizeDoesNotCollapseAdjacentOrOverlappingEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeBoundsDuplicateWarningIndices(t *testing.T) {
|
func TestNormalizeBoundsDuplicateFindingIndices(t *testing.T) {
|
||||||
doc := sourceDocument(2)
|
doc := sourceDocument(2)
|
||||||
ref := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}
|
ref := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}
|
||||||
casts := make([]dnd.SpellCast, 22)
|
casts := make([]dnd.SpellCast, 22)
|
||||||
@@ -423,7 +423,7 @@ func TestNormalizeBoundsDuplicateWarningIndices(t *testing.T) {
|
|||||||
}
|
}
|
||||||
message := diagnostic.Samples[0].Message
|
message := diagnostic.Samples[0].Message
|
||||||
if !strings.Contains(message, "removed input indices [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]") || strings.Contains(message, ", 21]") || !strings.Contains(message, "1 additional removed input indices omitted") {
|
if !strings.Contains(message, "removed input indices [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20]") || strings.Contains(message, ", 21]") || !strings.Contains(message, "1 additional removed input indices omitted") {
|
||||||
t.Fatalf("warning message = %q, want 20 displayed indices and exact omitted count", message)
|
t.Fatalf("finding message = %q, want 20 displayed indices and exact omitted count", message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"warning_reason_codes": [
|
"diagnostic_reason_codes": [
|
||||||
"spell_name_canonicalized",
|
"spell_name_canonicalized",
|
||||||
"source_references_normalized",
|
"source_references_normalized",
|
||||||
"duplicate_spell_cast_collapsed"
|
"duplicate_spell_cast_collapsed"
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"warning_reason_codes": []
|
"diagnostic_reason_codes": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
func TestValidatorApprovesNormalizedCombatTurns(t *testing.T) {
|
func TestValidatorApprovesNormalizedCombatTurns(t *testing.T) {
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: normalizedList()})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: invariantDocument(), Value: normalizedList()})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err)
|
t.Fatalf("Validate() = %#v, %v; want approval without diagnostics", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
func TestValidatorApprovesWellFormedCombatTurnList(t *testing.T) {
|
func TestValidatorApprovesWellFormedCombatTurnList(t *testing.T) {
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: validCombatTurnList()})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Value: validCombatTurnList()})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("Validate() = %#v, %v; want approval without warnings", result, err)
|
t.Fatalf("Validate() = %#v, %v; want approval without diagnostics", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func TestValidatorDefersMalformedShape(t *testing.T) {
|
|||||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
|
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value})
|
||||||
if err != nil || !result.Approved {
|
if err != nil || !result.Approved {
|
||||||
t.Fatalf("shape deferral = %#v, %v; want approval without source warning", result, err)
|
t.Fatalf("shape deferral = %#v, %v; want approval without diagnostics", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,13 +50,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTexts[turnIndex] = citedText
|
citedTexts[turnIndex] = citedText
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for turnIndex, turn := range req.Value.CombatTurns {
|
for turnIndex, turn := range req.Value.CombatTurns {
|
||||||
citedText := citedTexts[turnIndex]
|
citedText := citedTexts[turnIndex]
|
||||||
if actorAppearsInCitedText(citedText, turn.Actor) {
|
if actorAppearsInCitedText(citedText, turn.Actor) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
|
Scope: fmt.Sprintf("combat_turns[%d]", turnIndex),
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("combat turn not near source", []string{
|
Message: diagnostics.Aggregate("combat turn not near source", []string{
|
||||||
@@ -64,7 +64,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
func actorAppearsInCitedText(citedText string, actor string) bool {
|
func actorAppearsInCitedText(citedText string, actor string) bool {
|
||||||
return shared.ContainsTokenSequence(citedText, actor)
|
return shared.ContainsTokenSequence(citedText, actor)
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestValidatorWarnsOncePerTurnForUnrelatedActor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorLimitsUnrelatedActorWarnings(t *testing.T) {
|
func TestValidatorLimitsUnrelatedActorAdvisories(t *testing.T) {
|
||||||
turns := make([]dnd.CombatTurn, contracts.MaxDiagnosticSamples+2)
|
turns := make([]dnd.CombatTurn, contracts.MaxDiagnosticSamples+2)
|
||||||
for index := range turns {
|
for index := range turns {
|
||||||
turns[index] = dnd.CombatTurn{
|
turns[index] = dnd.CombatTurn{
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for eventIndex, event := range req.Value.Events {
|
for eventIndex, event := range req.Value.Events {
|
||||||
citedText, err := resolver.CitedText(event.SourceRefs)
|
citedText, err := resolver.CitedText(event.SourceRefs)
|
||||||
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
|
if err != nil || shared.ContainsTokenSequence(citedText, event.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("events[%d]", eventIndex),
|
Scope: fmt.Sprintf("events[%d]", eventIndex),
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: diagnostics.Aggregate("enemy event subject not near source", []string{
|
Message: diagnostics.Aggregate("enemy event subject not near source", []string{
|
||||||
@@ -56,7 +56,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestValidatorAcceptsUnicodeSubjectInCitedEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorDefersMalformedValuesAndBoundsWarnings(t *testing.T) {
|
func TestValidatorDefersMalformedValuesAndBoundsAdvisories(t *testing.T) {
|
||||||
malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}}
|
malformed := dnd.EnemyEventList{Events: []dnd.EnemyEvent{{Name: "Ashfang"}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: malformed})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.EnemyEventList]{Source: document("none"), Value: malformed})
|
||||||
if err != nil || !result.Approved || len(result.Diagnostics) != 0 {
|
if err != nil || !result.Approved || len(result.Diagnostics) != 0 {
|
||||||
|
|||||||
@@ -45,19 +45,19 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, occurrence := range req.Value.Occurrences {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
citedText, err := resolver.CitedText(occurrence.SourceRefs)
|
citedText, err := resolver.CitedText(occurrence.SourceRefs)
|
||||||
if err != nil || shared.ContainsTokenSequence(citedText, occurrence.Name) {
|
if err != nil || shared.ContainsTokenSequence(citedText, occurrence.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("occurrences[%d]", index),
|
Scope: fmt.Sprintf("occurrences[%d]", index),
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)),
|
Message: fmt.Sprintf("item occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ func TestValidatorDefersMalformedAndUnreadableEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsWarningsAndRegistersPolicy(t *testing.T) {
|
func TestValidatorBoundsAdvisoriesAndRegistersPolicy(t *testing.T) {
|
||||||
count := contracts.MaxDiagnosticSamples + 5
|
count := contracts.MaxDiagnosticSamples + 5
|
||||||
occurrences := make([]dnd.ItemOccurrence, count)
|
occurrences := make([]dnd.ItemOccurrence, count)
|
||||||
for index := range occurrences {
|
for index := range occurrences {
|
||||||
|
|||||||
@@ -50,17 +50,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTexts[itemIndex] = citedText
|
citedTexts[itemIndex] = citedText
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for itemIndex, item := range req.Value.Items {
|
for itemIndex, item := range req.Value.Items {
|
||||||
if shared.ContainsTokenSequence(citedTexts[itemIndex], item.Name) {
|
if shared.ContainsTokenSequence(citedTexts[itemIndex], item.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("items[%d]", itemIndex), ReasonCode: ReasonCode,
|
Scope: fmt.Sprintf("items[%d]", itemIndex), ReasonCode: ReasonCode,
|
||||||
Message: fmt.Sprintf("Item %s was not found in cited source text", diagnostics.Quote(item.Name)),
|
Message: fmt.Sprintf("Item %s was not found in cited source text", diagnostics.Quote(item.Name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -16,17 +16,17 @@ func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) {
|
|||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "They recover the star compass."}, {ID: 2, Text: "Unrelated text."}}}
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1, Text: "They recover the star compass."}, {ID: 2, Text: "Unrelated text."}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, Value: value})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.ItemRegistry]{Source: doc, Value: value})
|
||||||
if err != nil || !result.Approved || len(result.Diagnostics) != 0 {
|
if err != nil || !result.Approved || len(result.Diagnostics) != 0 {
|
||||||
t.Fatalf("cited match = %#v, %v; want approval without warnings", result, err)
|
t.Fatalf("cited match = %#v, %v; want approval without advisories", result, err)
|
||||||
}
|
}
|
||||||
value.Items[0].Name = "Glossary Relic"
|
value.Items[0].Name = "Glossary Relic"
|
||||||
before := value
|
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})
|
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.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)
|
t.Fatalf("reference-only match = %#v, %v; want advisory", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsWarningsAndRegisters(t *testing.T) {
|
func TestValidatorBoundsAdvisoriesAndRegisters(t *testing.T) {
|
||||||
items := make([]dnd.Item, contracts.MaxDiagnosticSamples+2)
|
items := make([]dnd.Item, contracts.MaxDiagnosticSamples+2)
|
||||||
for index := range items {
|
for index := range items {
|
||||||
items[index] = dnd.Item{ID: "item", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
items[index] = dnd.Item{ID: "item", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||||
|
|||||||
@@ -50,14 +50,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTexts[index] = citedText
|
citedTexts[index] = citedText
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, occurrence := range req.Value.Occurrences {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
|
if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
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))})
|
findings = append(findings, 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)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestValidatorUsesOnlyCitedTranscriptEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorDefersUnreadableEvidenceAndBoundsWarnings(t *testing.T) {
|
func TestValidatorDefersUnreadableEvidenceAndBoundsAdvisories(t *testing.T) {
|
||||||
invalid := occurrenceList("Missing")
|
invalid := occurrenceList("Missing")
|
||||||
invalid.Occurrences[0].SourceRefs[0].StartUnitID = 99
|
invalid.Occurrences[0].SourceRefs[0].StartUnitID = 99
|
||||||
invalid.Occurrences[0].SourceRefs[0].EndUnitID = 99
|
invalid.Occurrences[0].SourceRefs[0].EndUnitID = 99
|
||||||
|
|||||||
@@ -50,17 +50,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTexts[locationIndex] = citedText
|
citedTexts[locationIndex] = citedText
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for locationIndex, location := range req.Value.Locations {
|
for locationIndex, location := range req.Value.Locations {
|
||||||
if shared.ContainsTokenSequence(citedTexts[locationIndex], location.Name) {
|
if shared.ContainsTokenSequence(citedTexts[locationIndex], location.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("locations[%d]", locationIndex), ReasonCode: ReasonCode,
|
Scope: fmt.Sprintf("locations[%d]", locationIndex), ReasonCode: ReasonCode,
|
||||||
Message: fmt.Sprintf("Location %s was not found in cited source text", diagnostics.Quote(location.Name)),
|
Message: fmt.Sprintf("Location %s was not found in cited source text", diagnostics.Quote(location.Name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -17,14 +17,14 @@ func TestValidatorUsesOnlyCitedTranscriptText(t *testing.T) {
|
|||||||
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party enters o’rin’s gate."}, {ID: 2, Kind: "message", Text: "Unrelated location."}}}
|
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party enters o’rin’s gate."}, {ID: 2, Kind: "message", Text: "Unrelated location."}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), request(doc, value, contracts.ReferenceSet{}))
|
result, err := New(Options{}).Validate(context.Background(), request(doc, value, contracts.ReferenceSet{}))
|
||||||
if err != nil || !result.Approved || len(result.Diagnostics) != 0 {
|
if err != nil || !result.Approved || len(result.Diagnostics) != 0 {
|
||||||
t.Fatalf("cited match = %#v, %v; want approval without warnings", result, err)
|
t.Fatalf("cited match = %#v, %v; want approval without advisories", result, err)
|
||||||
}
|
}
|
||||||
value.Locations[0].Name = "Glossary Keep"
|
value.Locations[0].Name = "Glossary Keep"
|
||||||
before := value
|
before := value
|
||||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{"glossary": {Items: []contracts.ReferenceItem{{Content: []byte("Glossary Keep")}}}}}
|
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))
|
result, err = New(Options{}).Validate(context.Background(), request(doc, value, references))
|
||||||
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) {
|
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)
|
t.Fatalf("reference-only match = %#v, %v; want advisory", result, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ func TestValidatorRegisters(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsWarnings(t *testing.T) {
|
func TestValidatorBoundsAdvisories(t *testing.T) {
|
||||||
locations := make([]dnd.Location, contracts.MaxDiagnosticSamples+2)
|
locations := make([]dnd.Location, contracts.MaxDiagnosticSamples+2)
|
||||||
for index := range locations {
|
for index := range locations {
|
||||||
locations[index] = dnd.Location{ID: "candidate", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
locations[index] = dnd.Location{ID: "candidate", Name: "Missing", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}}
|
||||||
|
|||||||
@@ -50,18 +50,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTexts[index] = citedText
|
citedTexts[index] = citedText
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, occurrence := range req.Value.Occurrences {
|
for index, occurrence := range req.Value.Occurrences {
|
||||||
if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
|
if shared.ContainsTokenSequence(citedTexts[index], occurrence.Name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("occurrences[%d]", index),
|
Scope: fmt.Sprintf("occurrences[%d]", index),
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: fmt.Sprintf("NPC occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)),
|
Message: fmt.Sprintf("NPC occurrence name %s was not found in cited source text", diagnostics.Quote(occurrence.Name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorBoundsWarnings(t *testing.T) {
|
func TestValidatorBoundsAdvisories(t *testing.T) {
|
||||||
count := contracts.MaxDiagnosticSamples + 5
|
count := contracts.MaxDiagnosticSamples + 5
|
||||||
occurrences := make([]dnd.NPCOccurrence, count)
|
occurrences := make([]dnd.NPCOccurrence, count)
|
||||||
for index := range occurrences {
|
for index := range occurrences {
|
||||||
|
|||||||
@@ -49,18 +49,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTexts[npcIndex] = citedText
|
citedTexts[npcIndex] = citedText
|
||||||
}
|
}
|
||||||
var warnings []diagnostics.Finding
|
var findings []diagnostics.Finding
|
||||||
for npcIndex, npc := range req.Value.NPCs {
|
for npcIndex, npc := range req.Value.NPCs {
|
||||||
if npcAppearsInCitedText(citedTexts[npcIndex], npc) {
|
if npcAppearsInCitedText(citedTexts[npcIndex], npc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, diagnostics.Finding{
|
findings = append(findings, diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("npcs[%d]", npcIndex),
|
Scope: fmt.Sprintf("npcs[%d]", npcIndex),
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
Message: fmt.Sprintf("NPC %s was not found in cited source text", diagnostics.Quote(npc.Name)),
|
Message: fmt.Sprintf("NPC %s was not found in cited source text", diagnostics.Quote(npc.Name)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
|
func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
warnings := make([]diagnostics.Finding, 0)
|
findings := make([]diagnostics.Finding, 0)
|
||||||
for index, scene := range req.Value.Scenes {
|
for index, scene := range req.Value.Scenes {
|
||||||
citedText, err := resolver.CitedText([]source.SourceRef{scene.SourceRef})
|
citedText, err := resolver.CitedText([]source.SourceRef{scene.SourceRef})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -59,13 +59,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTokens := tokenSet(citedText)
|
citedTokens := tokenSet(citedText)
|
||||||
if !hasGroundedToken(citedTokens, scene.Title) {
|
if !hasGroundedToken(citedTokens, scene.Title) {
|
||||||
warnings = append(warnings, warning(index, "title"))
|
findings = append(findings, finding(index, "title"))
|
||||||
}
|
}
|
||||||
if !hasGroundedToken(citedTokens, scene.Summary) {
|
if !hasGroundedToken(citedTokens, scene.Summary) {
|
||||||
warnings = append(warnings, warning(index, "summary"))
|
findings = append(findings, finding(index, "summary"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return diagnostics.DataQualityResult(warnings)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tokenSet(value string) map[string]struct{} {
|
func tokenSet(value string) map[string]struct{} {
|
||||||
@@ -98,7 +98,7 @@ func significant(token string) bool {
|
|||||||
return !ignored
|
return !ignored
|
||||||
}
|
}
|
||||||
|
|
||||||
func warning(index int, field string) diagnostics.Finding {
|
func finding(index int, field string) diagnostics.Finding {
|
||||||
return diagnostics.Finding{
|
return diagnostics.Finding{
|
||||||
Scope: fmt.Sprintf("scenes[%d].%s", index, field),
|
Scope: fmt.Sprintf("scenes[%d].%s", index, field),
|
||||||
ReasonCode: ReasonCode,
|
ReasonCode: ReasonCode,
|
||||||
|
|||||||
@@ -49,13 +49,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}
|
}
|
||||||
citedTexts[spellIndex] = citedText
|
citedTexts[spellIndex] = citedText
|
||||||
}
|
}
|
||||||
var warnings []diagnostics.Finding
|
var findings []diagnostics.Finding
|
||||||
for spellIndex, spell := range req.Value.SpellCasts {
|
for spellIndex, spell := range req.Value.SpellCasts {
|
||||||
if !spellAppearsInCitedText(citedTexts[spellIndex], spell) {
|
if !spellAppearsInCitedText(citedTexts[spellIndex], 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)))})
|
findings = append(findings, 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)
|
return diagnostics.DataQualityResult(findings)
|
||||||
}
|
}
|
||||||
func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool {
|
func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool {
|
||||||
name := strings.TrimSpace(spell.Spell)
|
name := strings.TrimSpace(spell.Spell)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestValidatorApprovesWithoutWarningWhenSpellAppearsInCitedText(t *testing.T) {
|
func TestValidatorApprovesWithoutAdvisoryWhenSpellAppearsInCitedText(t *testing.T) {
|
||||||
result, err := New(Options{}).Validate(context.Background(), requestWithSpell(validDocument(), "Cure Wounds", 2))
|
result, err := New(Options{}).Validate(context.Background(), requestWithSpell(validDocument(), "Cure Wounds", 2))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Validate() error = %v, want nil", err)
|
t.Fatalf("Validate() error = %v, want nil", err)
|
||||||
@@ -99,7 +99,7 @@ func TestValidatorIgnoresInvalidCitations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
|
func TestValidatorApprovesEmptySpellListWithoutAdvisory(t *testing.T) {
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Validate() error = %v, want nil", err)
|
t.Fatalf("Validate() error = %v, want nil", err)
|
||||||
|
|||||||
@@ -281,6 +281,10 @@ type diagnosticsFile struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.OutputFile, error) {
|
func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.OutputFile, error) {
|
||||||
|
diagnosticProjection, err := contracts.ProjectDiagnosticCollection(req.Diagnostics)
|
||||||
|
if err != nil {
|
||||||
|
return nil, encoderErrorf("diagnostics: %w", err)
|
||||||
|
}
|
||||||
outputs := cloneNormalizeOutputs(req.NormalizeOutputs)
|
outputs := cloneNormalizeOutputs(req.NormalizeOutputs)
|
||||||
sort.SliceStable(outputs, func(i, j int) bool {
|
sort.SliceStable(outputs, func(i, j int) bool {
|
||||||
return outputs[i].LaneID < outputs[j].LaneID
|
return outputs[i].LaneID < outputs[j].LaneID
|
||||||
@@ -351,11 +355,11 @@ func logicalFiles(req contracts.OutputRequest, options Options) ([]contracts.Out
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
warningsOutput, err := jsonFile("warnings.json", newWarningsFile(req.Diagnostics))
|
warningsOutput, err := jsonFile("warnings.json", newWarningsFile(diagnosticProjection))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
diagnosticsOutput, err := jsonFile("diagnostics.json", newDiagnosticsFile(req.Diagnostics))
|
diagnosticsOutput, err := jsonFile("diagnostics.json", newDiagnosticsFile(req.Diagnostics, diagnosticProjection))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -510,56 +514,26 @@ func cloneRejected(rejected []contracts.RejectedOutput) []contracts.RejectedOutp
|
|||||||
return append([]contracts.RejectedOutput(nil), rejected...)
|
return append([]contracts.RejectedOutput(nil), rejected...)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newWarningsFile(collection contracts.DiagnosticCollection) warningsFile {
|
func newWarningsFile(projection contracts.DiagnosticProjection) warningsFile {
|
||||||
groups := diagnosticGroupsByDisposition(collection, contracts.DiagnosticDispositionWarning)
|
|
||||||
return warningsFile{
|
return warningsFile{
|
||||||
SchemaVersion: warningsSchemaVersion,
|
SchemaVersion: warningsSchemaVersion,
|
||||||
GroupCount: len(groups),
|
GroupCount: len(projection.Warnings),
|
||||||
OccurrenceCount: diagnosticOccurrenceCount(groups),
|
OccurrenceCount: projection.WarningOccurrenceCount,
|
||||||
Groups: groups,
|
Groups: projection.Warnings,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newDiagnosticsFile(collection contracts.DiagnosticCollection) diagnosticsFile {
|
func newDiagnosticsFile(collection contracts.DiagnosticCollection, projection contracts.DiagnosticProjection) diagnosticsFile {
|
||||||
groups := diagnosticGroupsExceptDisposition(collection, contracts.DiagnosticDispositionWarning)
|
|
||||||
return diagnosticsFile{
|
return diagnosticsFile{
|
||||||
SchemaVersion: diagnosticsSchemaVersion,
|
SchemaVersion: diagnosticsSchemaVersion,
|
||||||
GroupCount: len(groups),
|
GroupCount: len(projection.Diagnostics),
|
||||||
OccurrenceCount: diagnosticOccurrenceCount(groups) + collection.UnrepresentedOccurrenceCount,
|
OccurrenceCount: projection.DiagnosticOccurrenceCount,
|
||||||
Truncated: collection.Truncated,
|
Truncated: collection.Truncated,
|
||||||
UnrepresentedOccurrenceCount: collection.UnrepresentedOccurrenceCount,
|
UnrepresentedOccurrenceCount: collection.UnrepresentedOccurrenceCount,
|
||||||
Groups: groups,
|
Groups: projection.Diagnostics,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func diagnosticGroupsByDisposition(collection contracts.DiagnosticCollection, disposition contracts.DiagnosticDisposition) []contracts.DiagnosticGroup {
|
|
||||||
groups := make([]contracts.DiagnosticGroup, 0)
|
|
||||||
for _, group := range collection.Groups {
|
|
||||||
if group.Disposition == disposition {
|
|
||||||
groups = append(groups, group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: groups}).Groups
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnosticGroupsExceptDisposition(collection contracts.DiagnosticCollection, disposition contracts.DiagnosticDisposition) []contracts.DiagnosticGroup {
|
|
||||||
groups := make([]contracts.DiagnosticGroup, 0)
|
|
||||||
for _, group := range collection.Groups {
|
|
||||||
if group.Disposition != disposition {
|
|
||||||
groups = append(groups, group)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return contracts.CloneDiagnosticCollection(contracts.DiagnosticCollection{Groups: groups}).Groups
|
|
||||||
}
|
|
||||||
|
|
||||||
func diagnosticOccurrenceCount(groups []contracts.DiagnosticGroup) int {
|
|
||||||
count := 0
|
|
||||||
for _, group := range groups {
|
|
||||||
count += group.OccurrenceCount
|
|
||||||
}
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
func encoderErrorf(format string, args ...any) error {
|
func encoderErrorf(format string, args ...any) error {
|
||||||
return fmt.Errorf("json output encoder: "+format, args...)
|
return fmt.Errorf("json output encoder: "+format, args...)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user