From 79b9fffcaf73b661457d784d179c264fee444a61 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 20 Jul 2026 21:06:30 +0000 Subject: [PATCH] Register the D&D spell normalizer defaults --- examples/dnd-spells-production.config.yml | 4 ++ examples/dnd-spells.config.yml | 1 + internal/cli/example_contract_test.go | 7 ++- internal/cli/production_contract_test.go | 43 ++++++++++++++++--- .../spell_catalog_identity_contract_test.go | 25 ++++++++++- .../cli/spell_catalog_retry_contract_test.go | 4 +- internal/modules/dnd/register/register.go | 16 +++++++ .../modules/dnd/register/register_test.go | 8 ++++ 8 files changed, 97 insertions(+), 11 deletions(-) diff --git a/examples/dnd-spells-production.config.yml b/examples/dnd-spells-production.config.yml index 6ca3d7b..ca92caf 100644 --- a/examples/dnd-spells-production.config.yml +++ b/examples/dnd-spells-production.config.yml @@ -31,3 +31,7 @@ pipelines: retries: 2 references: spell_catalog: ./dnd-spells-catalog.json + normalize: + module: dnd/spells + references: + spell_catalog: ./dnd-spells-catalog.json diff --git a/examples/dnd-spells.config.yml b/examples/dnd-spells.config.yml index 1d3c8b0..be0bca8 100644 --- a/examples/dnd-spells.config.yml +++ b/examples/dnd-spells.config.yml @@ -15,3 +15,4 @@ pipelines: artifacts: spells: extract: dnd/spells + normalize: dnd/spells diff --git a/internal/cli/example_contract_test.go b/internal/cli/example_contract_test.go index 73fcc1d..e8e1e5b 100644 --- a/internal/cli/example_contract_test.go +++ b/internal/cli/example_contract_test.go @@ -12,6 +12,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/core/debugbundle" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" + spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" ) func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { @@ -31,7 +32,9 @@ func TestMaintainedExamplesLoadResolveAndList(t *testing.T) { t.Fatalf("materialize maintained example references: %v", err) } if example.name == "production" { - if len(materialized.ArtifactLanes) != 1 || len(materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 { + if len(materialized.ArtifactLanes) != 1 || + len(materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 || + len(materialized.ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items) != 1 { t.Fatalf("production spell catalog reference was not materialized: %#v", materialized.ArtifactLanes) } } @@ -80,7 +83,7 @@ func TestMaintainedMinimalInvocationProducesJSONBundle(t *testing.T) { t.Fatalf("manifest lanes = %#v, want exactly spells", manifest.ArtifactLanes) } lane := manifest.ArtifactLanes[0] - if lane.ID != "spells" || lane.Extractor != "dnd/spells" || lane.Merger != "appendorder" || lane.Normalizer != "noop" { + if lane.ID != "spells" || lane.Extractor != "dnd/spells" || lane.Merger != "appendorder" || lane.Normalizer != spellnormalize.Key { t.Fatalf("manifest lane = %#v, want production spells composition", lane) } if len(manifest.References) != 0 { diff --git a/internal/cli/production_contract_test.go b/internal/cli/production_contract_test.go index 2b36ded..39b4e99 100644 --- a/internal/cli/production_contract_test.go +++ b/internal/cli/production_contract_test.go @@ -24,6 +24,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" ) @@ -35,7 +36,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { assertProductionContains(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes", "generic"}) assertProductionContains(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"}) assertProductionContains(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"}) - assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop"}) + assertProductionContains(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop", spellnormalize.Key}) assertProductionContains(t, "outputs", registries.Outputs.RegisteredKeys(), []string{"json"}) assertProductionContains(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/spells/catalog", @@ -50,6 +51,7 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { assertProductionContains(t, "artifact codec kinds", registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind}) assertProductionContains(t, "merger variants", registries.Mergers.RegisteredArtifactKinds(pipeline.DefaultMergeModule), []contracts.ArtifactKind{dnd.SpellListKind}) assertProductionContains(t, "normalizer variants", registries.Normalizers.RegisteredArtifactKinds(pipeline.DefaultNormalizeModule), []contracts.ArtifactKind{dnd.SpellListKind}) + assertProductionContains(t, "spell normalizer variants", registries.Normalizers.RegisteredArtifactKinds(spellnormalize.Key), []contracts.ArtifactKind{dnd.SpellListKind}) wantChain := []pipeline.ModuleBinding{ pipeline.Binding("generic/valid_json"), @@ -62,6 +64,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("spell validator chain = %#v, want %#v", got, wantChain) } + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) { + t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain) + } assetNames := productionAssetNames(t, components.assets.PromptFS) requiredAssets := []string{ @@ -93,6 +98,9 @@ func TestProductionCatalogCoversMaintainedConfigurations(t *testing.T) { if got := catalog.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("catalog validator chain = %#v, want %#v", got, wantChain) } + if got := catalog.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) { + t.Fatalf("catalog spell normalize validator chain = %#v, want %#v", got, wantChain) + } } @@ -140,9 +148,13 @@ func TestProductionSpellValidatorsPrepareFromMaterializedCatalog(t *testing.T) { if err != nil { t.Fatalf("materialize production spell references: %v", err) } - items := materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items - if len(items) != 1 || items[0].MediaType != "application/json" || len(items[0].Content) == 0 { - t.Fatalf("materialized spell catalog items = %#v, want one JSON item", items) + extractItems := materialized.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["spell_catalog"].Items + normalizeItems := materialized.ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["spell_catalog"].Items + if len(extractItems) != 1 || extractItems[0].MediaType != "application/json" || len(extractItems[0].Content) == 0 { + t.Fatalf("materialized extract spell catalog items = %#v, want one JSON item", extractItems) + } + if len(normalizeItems) != 1 || normalizeItems[0].MediaType != "application/json" || !reflect.DeepEqual(normalizeItems[0].Content, extractItems[0].Content) { + t.Fatalf("materialized normalize spell catalog items = %#v, want an independent binding of the extract catalog", normalizeItems) } if _, err := pipeline.Prepare(materialized, components.registries, pipeline.ModuleDependencies{LLM: &productionFakeLLMClient{}}); err != nil { t.Fatalf("prepare production spell pipeline from materialized catalog: %v", err) @@ -235,7 +247,7 @@ func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T) name: "invalid artifact variant", content: base, options: productionCLIOptionsWithoutSpellNormalizer(t), - fragments: []string{"normalizer", "noop", string(dnd.SpellListKind), "variant"}, + fragments: []string{"normalizer", spellnormalize.Key, string(dnd.SpellListKind), "variant"}, }, { name: "deterministic validator with profile", @@ -261,6 +273,27 @@ func TestProductionConfigValidationCoversModuleAndVariantFailures(t *testing.T) } } +func TestProductionNormalizeValidatorOverrideRemainsAuthoritative(t *testing.T) { + base := string(readRepositoryFile(t, "examples", "dnd-spells.config.yml")) + content := replaceRequiredOnce(t, base, " normalize: dnd/spells\n", " normalize:\n module: dnd/spells\n validators:\n - module: generic/always_accept\n") + path := writeProductionContractConfig(t, content) + components := productionTestComponents(t) + effective, err := loadMaintainedExample(t, path).Resolve(resolveInputForMaintainedExample(components, "dnd-session")) + if err != nil { + t.Fatalf("resolve normalize override: %v", err) + } + for _, chain := range effective.ResolvedPipeline.ValidatorChains { + if chain.Stage != pipeline.StageNormalize || chain.ModuleKey != spellnormalize.Key { + continue + } + if len(chain.Validators) != 1 || chain.Validators[0].Binding.Module != "generic/always_accept" { + t.Fatalf("normalize validator chain = %#v, want explicit always-accept override", chain) + } + return + } + t.Fatalf("resolved validator chains = %#v, want normalize chain for %q", effective.ResolvedPipeline.ValidatorChains, spellnormalize.Key) +} + func TestProductionSceneRunRecordsChunkerWarningsAndProvenance(t *testing.T) { outputRoot := filepath.Join(t.TempDir(), "output") configPath := writeProductionContractConfig(t, productionRunConfig(outputRoot, "dnd/scenes")) diff --git a/internal/cli/spell_catalog_identity_contract_test.go b/internal/cli/spell_catalog_identity_contract_test.go index c1f9202..73959a9 100644 --- a/internal/cli/spell_catalog_identity_contract_test.go +++ b/internal/cli/spell_catalog_identity_contract_test.go @@ -17,6 +17,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog" ) @@ -128,8 +129,28 @@ func TestSemanticSpellCatalogFingerprintChangesCheckpointIdentityWithoutReferenc t.Fatal(err) } fingerprints := prepared.CheckpointFingerprints() - if len(fingerprints) != 2 || fingerprints[0].Value != fingerprints[1].Value { - t.Fatalf("prepared fingerprints = %#v, want matching extractor and validator catalog identities", fingerprints) + wantNames := map[string]struct{}{ + "extract:spells:" + spells.Key + ":effective_catalog": {}, + "extract:spells:" + spells.Key + ":validator:3:extract/dnd/spells/catalog:effective_catalog": {}, + "normalize:spells:" + spellnormalize.Key + ":effective_catalog": {}, + "normalize:spells:" + spellnormalize.Key + ":validator:3:extract/dnd/spells/catalog:effective_catalog": {}, + } + seen := make(map[string]string, len(fingerprints)) + for _, fingerprint := range fingerprints { + if _, ok := wantNames[fingerprint.Name]; ok { + seen[fingerprint.Name] = fingerprint.Value + } + } + if len(seen) != len(wantNames) { + t.Fatalf("prepared fingerprints = %#v, want scoped extractor and normalize catalog identities", fingerprints) + } + var catalogDigest string + for name, value := range seen { + if catalogDigest == "" { + catalogDigest = value + } else if value != catalogDigest { + t.Fatalf("prepared fingerprint %q = %q, want shared semantic catalog digest %q", name, value, catalogDigest) + } } identityFor := func(values []pipeline.CheckpointFingerprint) checkpoint.Identity { diff --git a/internal/cli/spell_catalog_retry_contract_test.go b/internal/cli/spell_catalog_retry_contract_test.go index 748cdaf..cd801e5 100644 --- a/internal/cli/spell_catalog_retry_contract_test.go +++ b/internal/cli/spell_catalog_retry_contract_test.go @@ -106,8 +106,8 @@ func TestProductionSpellCatalogValidationRetries(t *testing.T) { if len(value.SpellCasts) != 1 || value.SpellCasts[0].Spell != tt.wantSpell { t.Fatalf("normalized spell list = %#v, want accepted overlay spell", value) } - if len(output.Warnings) != 1 || output.Warnings[0].ReasonCode != tt.wantWarningCode { - t.Fatalf("warnings = %#v, want only accepted-attempt warning", output.Warnings) + if len(output.Warnings) != 2 || output.Warnings[0].ReasonCode != tt.wantWarningCode || output.Warnings[1].ReasonCode != tt.wantWarningCode { + t.Fatalf("warnings = %#v, want accepted-attempt warnings from extract and normalize validation", output.Warnings) } }) } diff --git a/internal/modules/dnd/register/register.go b/internal/modules/dnd/register/register.go index 9c961ee..656318c 100644 --- a/internal/modules/dnd/register/register.go +++ b/internal/modules/dnd/register/register.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog" spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape" spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs" @@ -38,6 +39,7 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { {name: "spell-list appendorder merger", register: func() error { return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists) }}, + {name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }}, {name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }}, {name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }}, {name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }}, @@ -71,6 +73,20 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { }); err != nil { return fmt.Errorf("register dnd spells validator chain: %w", err) } + if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{ + Stage: pipeline.StageNormalize, + Module: spellnormalize.Key, + Validators: []pipeline.ModuleBinding{ + pipeline.Binding(validjson.Key), + pipeline.Binding(validjsonschema.Key), + pipeline.Binding(spellshape.Key), + pipeline.Binding(spellcatalog.Key), + pipeline.Binding(spellsourcerefs.Key), + pipeline.Binding(spellrelatedness.Key), + }, + }); err != nil { + return fmt.Errorf("register dnd spells normalize validator chain: %w", err) + } return nil } diff --git a/internal/modules/dnd/register/register_test.go b/internal/modules/dnd/register/register_test.go index 71fc689..12b9c9f 100644 --- a/internal/modules/dnd/register/register_test.go +++ b/internal/modules/dnd/register/register_test.go @@ -12,6 +12,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" + spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells" ) func TestRegisterAddsDNDFamily(t *testing.T) { @@ -22,6 +23,7 @@ func TestRegisterAddsDNDFamily(t *testing.T) { } assertContainsKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"dnd/scenes"}) assertContainsKeys(t, "extractors", registries.Extractors.RegisteredKeys(), []string{"dnd/spells"}) + assertContainsKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{spellnormalize.Key, pipeline.DefaultNormalizeModule}) assertContainsArtifactKinds(t, registries.ArtifactCodecs.RegisteredKinds(), []contracts.ArtifactKind{dnd.SpellListKind}) assertContainsKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "extract/dnd/spells/catalog", @@ -42,6 +44,9 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if got := registries.ValidatorChains.Validators(pipeline.StageExtract, spells.Key); !reflect.DeepEqual(got, wantChain) { t.Fatalf("spell validator chain = %#v, want %#v", got, wantChain) } + if got := registries.ValidatorChains.Validators(pipeline.StageNormalize, spellnormalize.Key); !reflect.DeepEqual(got, wantChain) { + t.Fatalf("spell normalize validator chain = %#v, want %#v", got, wantChain) + } assertAssetNamesContain(t, assets.PromptFS, []string{ "dnd.scenes/dnd.scenes.yaml", "dnd.scenes/instructions.md", @@ -66,6 +71,9 @@ func TestRegisterAddsDNDFamily(t *testing.T) { if spec, ok := registries.Extractors.Spec(spells.Key); !ok || spec.ArtifactKind != dnd.SpellListKind { t.Fatalf("spell extractor spec = %#v, present = %t; want dnd spell-list artifact", spec, ok) } + if spec, ok := registries.Normalizers.Spec(spellnormalize.Key); !ok || spec.ArtifactKind != dnd.SpellListKind || spec.Stage != pipeline.StageNormalize { + t.Fatalf("spell normalizer spec = %#v, present = %t; want dnd spell-list artifact", spec, ok) + } } func TestRegisterRejectsMissingDNDDependenciesBeforeMutation(t *testing.T) {