package pipeline

import (
	"context"
	"encoding/json"
	"errors"
	"reflect"
	"strings"
	"testing"

	"gitea.maximumdirect.net/eric/notarius/internal/core/source"
	"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)

func TestResolvePipelineWithExplicitModules(t *testing.T) {
	catalog := newProfileCatalog(t)
	registerProfileSpecs(t, catalog,
		ModuleSpec{Key: "window", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"source"}, Provides: []string{"chunk"}},
		ModuleSpec{Key: "record-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"chunk"}, Provides: []string{"candidate"}},
		ModuleSpec{Key: "dedupe", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"candidate"}, Provides: []string{"merged"}},
		ModuleSpec{Key: "canonical", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"merged"}, Provides: []string{"normalized"}},
		ModuleSpec{Key: "ndjson", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"normalized"}, Provides: []string{"encoded"}},
	)

	resolved, err := ResolvePipeline(PipelineProfile{
		ID:    " campaign ",
		Input: Binding(" text "),
		Chunk: ModuleBinding{Module: " window ", Options: map[string]any{
			"size": 10,
		}},
		Artifacts: map[string]ArtifactLaneProfile{
			" records ": {
				Extract:   Binding(" record-extractor "),
				Merge:     Binding(" dedupe "),
				Normalize: Binding(" canonical "),
			},
		},
		Output: Binding(" ndjson "),
	}, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	if resolved.ID != "campaign" {
		t.Fatalf("ID = %q, want campaign", resolved.ID)
	}
	if !reflect.DeepEqual(resolved.Input, ModuleBinding{Module: "text"}) {
		t.Fatalf("Input = %#v, want trimmed explicit input", resolved.Input)
	}
	if resolved.InputExecutionClass != contracts.ExecutionClassDeterministic {
		t.Fatalf("InputExecutionClass = %q, want deterministic", resolved.InputExecutionClass)
	}
	if resolved.Chunk.Module != "window" || resolved.Chunk.LLMProfile != "" {
		t.Fatalf("Chunk = %#v, want explicit module and empty LLM profile", resolved.Chunk)
	}
	if resolved.Chunk.Options["size"] != 10 {
		t.Fatalf("Chunk.Options = %#v, want size option", resolved.Chunk.Options)
	}
	if resolved.ChunkExecutionClass != contracts.ExecutionClassDeterministic {
		t.Fatalf("ChunkExecutionClass = %q, want deterministic", resolved.ChunkExecutionClass)
	}
	if len(resolved.Steps) != 1 || resolved.Steps[0].ID != "default" || len(resolved.Steps[0].ArtifactLanes) != 1 {
		t.Fatalf("resolved steps = %#v, want one default step with one lane", resolved.Steps)
	}
	lane := resolved.Steps[0].ArtifactLanes[0]
	if lane.ID != "records" {
		t.Fatalf("lane.ID = %q, want records", lane.ID)
	}
	if !reflect.DeepEqual(lane.Extract, ModuleBinding{Module: "record-extractor"}) {
		t.Fatalf("lane.Extract = %#v, want explicit extractor", lane.Extract)
	}
	if lane.ExtractExecutionClass != contracts.ExecutionClassDeterministic || lane.MergeExecutionClass != contracts.ExecutionClassDeterministic || lane.NormalizeExecutionClass != contracts.ExecutionClassDeterministic {
		t.Fatalf("lane execution classes = %q/%q/%q, want deterministic", lane.ExtractExecutionClass, lane.MergeExecutionClass, lane.NormalizeExecutionClass)
	}
	if lane.Merge.Module != "dedupe" || lane.Normalize.Module != "canonical" {
		t.Fatalf("lane merge/normalize = %#v/%#v, want explicit modules", lane.Merge, lane.Normalize)
	}
	if len(lane.Validators) != 0 {
		t.Fatalf("lane.Validators = %#v, want none", lane.Validators)
	}
	if resolved.Output.Module != "ndjson" {
		t.Fatalf("Output.Module = %q, want ndjson", resolved.Output.Module)
	}
	if resolved.OutputExecutionClass != contracts.ExecutionClassDeterministic {
		t.Fatalf("OutputExecutionClass = %q, want deterministic", resolved.OutputExecutionClass)
	}
	if !strings.HasPrefix(resolved.Digest, "sha256:") {
		t.Fatalf("Digest = %q, want sha256 digest", resolved.Digest)
	}
}

func TestModuleCatalogExecutionClassLooksUpRegisteredMetadata(t *testing.T) {
	catalog := newProfileCatalogWithOverrides(t,
		ModuleSpec{Key: "llm-input", Stage: StageInput, ExecutionClass: contracts.ExecutionClassLLMBacked},
		ModuleSpec{Key: "llm-chunk", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassLLMBacked},
		ModuleSpec{Key: "llm-extract", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked},
		ModuleSpec{Key: "llm-merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassLLMBacked},
		ModuleSpec{Key: "llm-normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked},
		ModuleSpec{Key: "llm-output", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassLLMBacked},
	)
	registerProfileValidatorSpec(t, catalog, ValidatorSpec{Key: "llm-validator", ExecutionClass: contracts.ExecutionClassLLMBacked})

	for _, test := range []struct {
		stage ModuleStage
		key   string
		want  contracts.ExecutionClass
	}{
		{stage: StageInput, key: "llm-input", want: contracts.ExecutionClassLLMBacked},
		{stage: StageChunk, key: "llm-chunk", want: contracts.ExecutionClassLLMBacked},
		{stage: StageExtract, key: "llm-extract", want: contracts.ExecutionClassLLMBacked},
		{stage: StageMerge, key: "llm-merge", want: contracts.ExecutionClassLLMBacked},
		{stage: StageNormalize, key: "llm-normalize", want: contracts.ExecutionClassLLMBacked},
		{stage: StageValidate, key: "llm-validator", want: contracts.ExecutionClassLLMBacked},
		{stage: StageOutput, key: "llm-output", want: contracts.ExecutionClassLLMBacked},
	} {
		t.Run(string(test.stage), func(t *testing.T) {
			got, ok := catalog.ExecutionClass(test.stage, test.key)
			if !ok || got != test.want {
				t.Fatalf("ExecutionClass(%q, %q) = %q, %t; want %q, true", test.stage, test.key, got, ok, test.want)
			}
		})
	}

	if _, ok := catalog.ExecutionClass(StageExtract, "missing"); ok {
		t.Fatal("ExecutionClass() found an unregistered module")
	}
}

func TestResolvePipelineCarriesCorrectionProtocolsIntoPreparedMetadata(t *testing.T) {
	correction := contracts.CorrectionProtocolSingleResponseV1
	catalog := newProfileCatalogWithOverrides(t,
		ModuleSpec{Key: "llm-input", Stage: StageInput, ExecutionClass: contracts.ExecutionClassLLMBacked, Provides: []string{"source"}},
		ModuleSpec{Key: "llm-chunk", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: correction, Requires: []string{"source"}, Provides: []string{"chunk"}},
		ModuleSpec{Key: "llm-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: correction, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}},
		ModuleSpec{Key: "llm-merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: correction, ArtifactKind: "test/notes", Requires: []string{"candidate"}, Provides: []string{"merged"}},
		ModuleSpec{Key: "llm-normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: correction, ArtifactKind: "test/notes", Requires: []string{"merged"}, Provides: []string{"normalized"}},
		ModuleSpec{Key: "llm-output", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"normalized"}, Provides: []string{"encoded"}},
	)
	resolved, err := ResolvePipeline(llmProfilePipeline(), ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if resolved.ChunkCorrectionProtocol != correction {
		t.Fatalf("ChunkCorrectionProtocol = %q, want %q", resolved.ChunkCorrectionProtocol, correction)
	}
	lane := resolved.Steps[0].ArtifactLanes[0]
	if lane.ExtractCorrectionProtocol != correction || lane.MergeCorrectionProtocol != correction || lane.NormalizeCorrectionProtocol != correction {
		t.Fatalf("lane correction protocols = %q/%q/%q, want %q", lane.ExtractCorrectionProtocol, lane.MergeCorrectionProtocol, lane.NormalizeCorrectionProtocol, correction)
	}
	withoutCapability := cloneResolvedPipeline(resolved)
	withoutCapability.ChunkCorrectionProtocol = ""
	withoutCapability.Steps[0].ArtifactLanes[0].ExtractCorrectionProtocol = ""
	withoutCapability.Steps[0].ArtifactLanes[0].MergeCorrectionProtocol = ""
	withoutCapability.Steps[0].ArtifactLanes[0].NormalizeCorrectionProtocol = ""
	withoutCapabilityDigest, err := resolvedPipelineDigest(withoutCapability)
	if err != nil {
		t.Fatalf("resolvedPipelineDigest() error = %v", err)
	}
	if withoutCapabilityDigest == resolved.Digest {
		t.Fatalf("resolved digest = %q with and without correction capability, want changed", resolved.Digest)
	}

	prepared, err := Prepare(resolved, registriesFromModuleCatalog(catalog), ModuleDependencies{})
	if err != nil {
		t.Fatalf("Prepare() error = %v, want nil", err)
	}
	if prepared.ChunkCorrectionProtocol != correction {
		t.Fatalf("prepared ChunkCorrectionProtocol = %q, want %q", prepared.ChunkCorrectionProtocol, correction)
	}
	preparedLane := prepared.Steps[0].ArtifactLanes[0]
	if preparedLane.ExtractCorrectionProtocol != correction || preparedLane.MergeCorrectionProtocol != correction || preparedLane.NormalizeCorrectionProtocol != correction {
		t.Fatalf("prepared lane correction protocols = %q/%q/%q, want %q", preparedLane.ExtractCorrectionProtocol, preparedLane.MergeCorrectionProtocol, preparedLane.NormalizeCorrectionProtocol, correction)
	}
}

func TestPrepareRequiresCorrectionCapabilityForValidatorRetries(t *testing.T) {
	for _, test := range []struct {
		name       string
		protocol   contracts.CorrectionProtocol
		retries    int
		validators bool
		want       string
	}{
		{name: "supported", protocol: contracts.CorrectionProtocolSingleResponseV1, retries: 1, validators: true},
		{name: "unsupported", retries: 1, validators: true, want: "does not declare correction protocol"},
		{name: "no validators", retries: 1},
		{name: "no retries", validators: true},
	} {
		t.Run(test.name, func(t *testing.T) {
			catalog := newProfileCatalogWithOverrides(t,
				ModuleSpec{Key: "llm-input", Stage: StageInput, ExecutionClass: contracts.ExecutionClassLLMBacked, Provides: []string{"source"}},
				ModuleSpec{Key: "llm-chunk", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassLLMBacked, CorrectionProtocol: test.protocol, Requires: []string{"source"}, Provides: []string{"chunk"}},
				ModuleSpec{Key: "llm-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}},
				ModuleSpec{Key: "llm-merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassLLMBacked, ArtifactKind: "test/notes", Requires: []string{"candidate"}, Provides: []string{"merged"}},
				ModuleSpec{Key: "llm-normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, ArtifactKind: "test/notes", Requires: []string{"merged"}, Provides: []string{"normalized"}},
				ModuleSpec{Key: "llm-output", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"normalized"}, Provides: []string{"encoded"}},
			)
			if err := RegisterChunkValidator(catalog.Validators, ValidatorSpec{Key: "chunk-validator", ExecutionClass: contracts.ExecutionClassLLMBacked}, func() (contracts.ChunkValidator, error) {
				return llmProfileTestChunkValidator{key: "chunk-validator"}, nil
			}); err != nil {
				t.Fatal(err)
			}
			profile := llmProfilePipeline()
			profile.Chunk.Retries = test.retries
			if test.validators {
				profile.Chunk.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "chunk-validator"}}}
			}
			resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
			if err != nil {
				t.Fatalf("ResolvePipeline() error = %v, want nil", err)
			}
			_, err = Prepare(resolved, registriesFromModuleCatalog(catalog), ModuleDependencies{})
			if test.want != "" {
				if err == nil || !strings.Contains(err.Error(), test.want) {
					t.Fatalf("Prepare() error = %v, want %q", err, test.want)
				}
				return
			}
			if err != nil {
				t.Fatalf("Prepare() error = %v, want nil", err)
			}
		})
	}
}

type llmProfileTestChunkValidator struct{ key string }

func (validator llmProfileTestChunkValidator) Name() string { return validator.key }

func (llmProfileTestChunkValidator) ExecutionClass() contracts.ExecutionClass {
	return contracts.ExecutionClassLLMBacked
}

func (llmProfileTestChunkValidator) Validate(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) {
	return contracts.ValidationResult{Approved: true}, nil
}

func TestResolvePipelineValidatorRetriesRequireLLMBackedValidator(t *testing.T) {
	for _, test := range []struct {
		name  string
		class contracts.ExecutionClass
		want  string
	}{
		{name: "deterministic validator", class: contracts.ExecutionClassDeterministic, want: "retries require an LLM-backed"},
		{name: "LLM validator", class: contracts.ExecutionClassLLMBacked},
	} {
		t.Run(test.name, func(t *testing.T) {
			catalog := newProfileCatalog(t)
			registerProfileValidatorSpec(t, catalog, ValidatorSpec{Key: "retrying-validator", ExecutionClass: test.class})
			profile := baselineProfile()
			lane := profile.Artifacts["events"]
			lane.Extract.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "retrying-validator", Retries: 1}}}
			profile.Artifacts["events"] = lane
			resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
			if test.want != "" {
				if err == nil || !strings.Contains(err.Error(), test.want) {
					t.Fatalf("ResolvePipeline() error = %v, want %q", err, test.want)
				}
				return
			}
			if err != nil {
				t.Fatalf("ResolvePipeline() error = %v, want nil", err)
			}
			if got := resolved.ValidatorChains[1].Validators[0].Binding.Retries; got != 1 {
				t.Fatalf("validator retries = %d, want 1", got)
			}
		})
	}
}

func TestResolvePipelineAppliesDefaults(t *testing.T) {
	resolved, err := ResolvePipeline(PipelineProfile{
		ID:    "defaulted",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {Extract: Binding("event-extractor")},
		},
	}, ResolveOptions{}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	if resolved.Input.LLMProfile != "" {
		t.Fatalf("Input.LLMProfile = %q, want empty", resolved.Input.LLMProfile)
	}
	if !reflect.DeepEqual(resolved.Chunk, ModuleBinding{Module: DefaultChunkModule}) {
		t.Fatalf("Chunk = %#v, want default chunk binding", resolved.Chunk)
	}
	if !reflect.DeepEqual(resolved.Output, ModuleBinding{Module: DefaultOutputModule}) {
		t.Fatalf("Output = %#v, want default output binding", resolved.Output)
	}
	lane := resolved.Steps[0].ArtifactLanes[0]
	if !reflect.DeepEqual(lane.Merge, ModuleBinding{Module: DefaultMergeModule}) {
		t.Fatalf("Merge = %#v, want default merge binding", lane.Merge)
	}
	if !reflect.DeepEqual(lane.Normalize, ModuleBinding{Module: DefaultNormalizeModule}) {
		t.Fatalf("Normalize = %#v, want default normalize binding", lane.Normalize)
	}
	if lane.Extract.LLMProfile != "" {
		t.Fatalf("Extract.LLMProfile = %q, want empty", lane.Extract.LLMProfile)
	}
}

func TestResolvePipelineRejectsReferenceSlotCollisionsAfterTrimming(t *testing.T) {
	for _, tt := range []struct {
		name      string
		configure func(*PipelineProfile)
		want      []string
	}{
		{
			name: "chunk",
			configure: func(profile *PipelineProfile) {
				profile.Chunk = ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
			},
			want: []string{`pipeline "references"`, "chunk", `reference slot "rules" is duplicated after trimming`},
		},
		{
			name: "extract",
			configure: func(profile *PipelineProfile) {
				lane := profile.Artifacts["lane"]
				lane.Extract.References = map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}
				profile.Artifacts["lane"] = lane
			},
			want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "extract", `reference slot "rules" is duplicated after trimming`},
		},
		{
			name: "merge",
			configure: func(profile *PipelineProfile) {
				lane := profile.Artifacts["lane"]
				lane.Merge = ModuleBinding{Module: DefaultMergeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
				profile.Artifacts["lane"] = lane
			},
			want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "merge", `reference slot "rules" is duplicated after trimming`},
		},
		{
			name: "normalize",
			configure: func(profile *PipelineProfile) {
				lane := profile.Artifacts["lane"]
				lane.Normalize = ModuleBinding{Module: DefaultNormalizeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}}
				profile.Artifacts["lane"] = lane
			},
			want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "normalize", `reference slot "rules" is duplicated after trimming`},
		},
	} {
		t.Run(tt.name, func(t *testing.T) {
			profile := PipelineProfile{
				ID:    "references",
				Input: Binding("text"),
				Artifacts: map[string]ArtifactLaneProfile{
					"lane": {Extract: Binding("event-extractor")},
				},
			}
			tt.configure(&profile)
			_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
			if err == nil {
				t.Fatal("ResolvePipeline() error = nil, want normalized reference collision")
			}
			for _, fragment := range tt.want {
				if !strings.Contains(err.Error(), fragment) {
					t.Fatalf("ResolvePipeline() error = %q, want context %q", err, fragment)
				}
			}
		})
	}
}

func TestResolvePipelineRejectsEmptyReferenceSlotAfterTrimming(t *testing.T) {
	_, err := ResolvePipeline(PipelineProfile{
		ID:    "references",
		Input: Binding("text"),
		Chunk: ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{
			" \t ": ExternalReference("rules.md"),
		}},
		Artifacts: map[string]ArtifactLaneProfile{
			"lane": {Extract: Binding("event-extractor")},
		},
	}, ResolveOptions{}, newProfileCatalog(t))
	if err == nil || !strings.Contains(err.Error(), `pipeline "references" chunk reference slot must not be empty`) {
		t.Fatalf("ResolvePipeline() error = %v, want contextual empty reference-slot rejection", err)
	}
}

func TestResolvePipelineAppliesEffectiveLLMProfiles(t *testing.T) {
	for _, test := range []struct {
		name    string
		profile PipelineProfile
		options ResolveOptions
		want    map[string]string
	}{
		{
			name: "runtime override",
			profile: func() PipelineProfile {
				profile := llmProfilePipeline()
				profile.LLMProfile = " pipeline "
				profile.Chunk.LLMProfile = "binding"
				return profile
			}(),
			options: ResolveOptions{LLMProfileOverride: " runtime "},
			want:    llmProfileValues("runtime"),
		},
		{
			name: "binding exception",
			profile: func() PipelineProfile {
				profile := llmProfilePipeline()
				profile.LLMProfile = "pipeline"
				lane := profile.Artifacts["events"]
				lane.Extract.LLMProfile = "extract"
				lane.Extract.Validators = ValidatorOverride{
					Set:        true,
					Validators: []ModuleBinding{{Module: "llm-validator", LLMProfile: "validator"}},
				}
				profile.Artifacts["events"] = lane
				return profile
			}(),
			want: func() map[string]string {
				values := llmProfileValues("pipeline")
				values["extract"] = "extract"
				values["validator:extract:events"] = "validator"
				return values
			}(),
		},
		{
			name: "pipeline default",
			profile: func() PipelineProfile {
				profile := llmProfilePipeline()
				profile.LLMProfile = "pipeline"
				return profile
			}(),
			want: llmProfileValues("pipeline"),
		},
		{
			name:    "prompt fallback",
			profile: llmProfilePipeline(),
			want:    llmProfileValues(""),
		},
	} {
		t.Run(test.name, func(t *testing.T) {
			resolved, err := ResolvePipeline(test.profile, test.options, llmProfileCatalog(t))
			if err != nil {
				t.Fatalf("ResolvePipeline() error = %v, want nil", err)
			}
			if got := resolvedLLMProfileValues(resolved); !reflect.DeepEqual(got, test.want) {
				t.Fatalf("resolved profiles = %#v, want %#v", got, test.want)
			}
		})
	}
}

func TestResolvePipelineAppliesProfilesOnlyToSelectedLLMBackedBindings(t *testing.T) {
	profile := llmProfilePipeline()
	profile.LLMProfile = "pipeline"
	profile.Artifacts["notes"] = ArtifactLaneProfile{Extract: Binding("llm-extractor")}

	resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, llmProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if got := laneIDs(resolved.Steps[0].ArtifactLanes); !reflect.DeepEqual(got, []string{"events"}) {
		t.Fatalf("selected lanes = %#v, want events only", got)
	}
	if got := resolvedLLMProfileValues(resolved); !reflect.DeepEqual(got, llmProfileValues("pipeline")) {
		t.Fatalf("resolved profiles = %#v, want selected LLM bindings only", got)
	}
}

func TestResolvePipelineLeavesUnusedProfilesOffDeterministicBindings(t *testing.T) {
	profile := baselineProfile()
	profile.LLMProfile = "unused"

	resolved, err := ResolvePipeline(profile, ResolveOptions{LLMProfileOverride: "also-unused"}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if resolved.Input.LLMProfile != "" || resolved.Chunk.LLMProfile != "" || resolved.Output.LLMProfile != "" {
		t.Fatalf("deterministic pipeline profiles = input %q chunk %q output %q, want empty", resolved.Input.LLMProfile, resolved.Chunk.LLMProfile, resolved.Output.LLMProfile)
	}
	lane := resolved.Steps[0].ArtifactLanes[0]
	if lane.Extract.LLMProfile != "" || lane.Merge.LLMProfile != "" || lane.Normalize.LLMProfile != "" {
		t.Fatalf("deterministic lane profiles = extract %q merge %q normalize %q, want empty", lane.Extract.LLMProfile, lane.Merge.LLMProfile, lane.Normalize.LLMProfile)
	}
}

func TestResolvePipelineRejectsLLMProfileForDeterministicModule(t *testing.T) {
	for _, test := range []struct {
		name   string
		mutate func(*PipelineProfile)
	}{
		{name: "input", mutate: func(profile *PipelineProfile) { profile.Input.LLMProfile = "invalid" }},
		{name: "chunk", mutate: func(profile *PipelineProfile) { profile.Chunk.LLMProfile = "invalid" }},
		{name: "extract", mutate: func(profile *PipelineProfile) {
			lane := profile.Artifacts["events"]
			lane.Extract.LLMProfile = "invalid"
			profile.Artifacts["events"] = lane
		}},
		{name: "merge", mutate: func(profile *PipelineProfile) {
			lane := profile.Artifacts["events"]
			lane.Merge.LLMProfile = "invalid"
			profile.Artifacts["events"] = lane
		}},
		{name: "normalize", mutate: func(profile *PipelineProfile) {
			lane := profile.Artifacts["events"]
			lane.Normalize.LLMProfile = "invalid"
			profile.Artifacts["events"] = lane
		}},
		{name: "output", mutate: func(profile *PipelineProfile) { profile.Output.LLMProfile = "invalid" }},
	} {
		t.Run(test.name, func(t *testing.T) {
			profile := baselineProfile()
			test.mutate(&profile)
			_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
			if err == nil || !strings.Contains(err.Error(), "llm_profile") || !strings.Contains(err.Error(), "deterministic") {
				t.Fatalf("ResolvePipeline() error = %v, want deterministic profile rejection", err)
			}
		})
	}
}

func TestResolvePipelineDigestUsesEffectiveLLMProfiles(t *testing.T) {
	inherited := llmProfilePipeline()
	inherited.LLMProfile = "shared"
	explicit := llmProfilePipeline()
	explicit.Input.LLMProfile = "shared"
	explicit.Chunk.LLMProfile = "shared"
	explicit.Output.LLMProfile = "shared"
	lane := explicit.Artifacts["events"]
	lane.Extract.LLMProfile = "shared"
	lane.Merge.LLMProfile = "shared"
	lane.Normalize.LLMProfile = "shared"
	explicit.Artifacts["events"] = lane

	inheritedResolved, err := ResolvePipeline(inherited, ResolveOptions{}, llmProfileCatalogWithoutValidatorChains(t))
	if err != nil {
		t.Fatalf("ResolvePipeline(inherited) error = %v, want nil", err)
	}
	explicitResolved, err := ResolvePipeline(explicit, ResolveOptions{}, llmProfileCatalogWithoutValidatorChains(t))
	if err != nil {
		t.Fatalf("ResolvePipeline(explicit) error = %v, want nil", err)
	}
	if inheritedResolved.Digest != explicitResolved.Digest {
		t.Fatalf("effective profile digests differ: %q != %q", inheritedResolved.Digest, explicitResolved.Digest)
	}

	explicit.Chunk.LLMProfile = "different"
	changedResolved, err := ResolvePipeline(explicit, ResolveOptions{}, llmProfileCatalogWithoutValidatorChains(t))
	if err != nil {
		t.Fatalf("ResolvePipeline(changed) error = %v, want nil", err)
	}
	if explicitResolved.Digest == changedResolved.Digest {
		t.Fatalf("digest = %q after effective profile change, want different", explicitResolved.Digest)
	}
}

func TestResolvePipelineAppliesStructuredOutputRepairAttemptsToLLMBindings(t *testing.T) {
	pipelineAttempts := 2
	chunkAttempts := 1
	extractAttempts := 0
	validatorAttempts := 3
	profile := llmProfilePipeline()
	profile.StructuredOutputRepairAttempts = &pipelineAttempts
	profile.Chunk.StructuredOutputRepairAttempts = &chunkAttempts
	lane := profile.Artifacts["events"]
	lane.Extract.StructuredOutputRepairAttempts = &extractAttempts
	lane.Extract.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "llm-validator", StructuredOutputRepairAttempts: &validatorAttempts}}}
	profile.Artifacts["events"] = lane

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, llmProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	encoded, err := json.Marshal(resolved)
	if err != nil {
		t.Fatalf("json.Marshal(resolved) error = %v", err)
	}
	if !strings.Contains(string(encoded), `"structured_output_repair_attempts":2`) {
		t.Fatalf("resolved JSON = %s, want effective repair policy", encoded)
	}
	resolvedLane := resolved.Steps[0].ArtifactLanes[0]
	for _, test := range []struct {
		name string
		got  *int
		want int
	}{
		{name: "input", got: resolved.Input.StructuredOutputRepairAttempts, want: pipelineAttempts},
		{name: "chunk binding", got: resolved.Chunk.StructuredOutputRepairAttempts, want: chunkAttempts},
		{name: "extract binding", got: resolvedLane.Extract.StructuredOutputRepairAttempts, want: extractAttempts},
		{name: "merge binding", got: resolvedLane.Merge.StructuredOutputRepairAttempts, want: pipelineAttempts},
		{name: "normalize binding", got: resolvedLane.Normalize.StructuredOutputRepairAttempts, want: pipelineAttempts},
		{name: "output", got: resolved.Output.StructuredOutputRepairAttempts, want: pipelineAttempts},
	} {
		t.Run(test.name, func(t *testing.T) {
			if test.got == nil || *test.got != test.want {
				t.Fatalf("repair attempts = %v, want %d", test.got, test.want)
			}
		})
	}

	extractChain := findResolvedValidatorChain(resolved.ValidatorChains, StageExtract, "events", "llm-extractor")
	if extractChain == nil || len(extractChain.Validators) != 1 {
		t.Fatalf("resolved extract validator chain = %#v, want one validator", extractChain)
	}
	if got := extractChain.Validators[0].Binding.StructuredOutputRepairAttempts; got == nil || *got != validatorAttempts {
		t.Fatalf("extract validator repair attempts = %v, want %d", got, validatorAttempts)
	}
	chunkChain := findResolvedValidatorChain(resolved.ValidatorChains, StageChunk, "", "llm-chunk")
	if chunkChain == nil || len(chunkChain.Validators) != 1 {
		t.Fatalf("resolved chunk validator chain = %#v, want one validator", chunkChain)
	}
	if got := chunkChain.Validators[0].Binding.StructuredOutputRepairAttempts; got == nil || *got != pipelineAttempts {
		t.Fatalf("chunk validator repair attempts = %v, want %d", got, pipelineAttempts)
	}

	pipelineAttempts = 1
	chunkAttempts = 2
	extractAttempts = 3
	validatorAttempts = 0
	if got := *resolved.Input.StructuredOutputRepairAttempts; got != 2 {
		t.Fatalf("resolved input repair attempts aliased profile: got %d, want 2", got)
	}
	if got := *resolved.Chunk.StructuredOutputRepairAttempts; got != 1 {
		t.Fatalf("resolved chunk repair attempts aliased profile: got %d, want 1", got)
	}
	if got := *resolvedLane.Extract.StructuredOutputRepairAttempts; got != 0 {
		t.Fatalf("resolved extract repair attempts aliased profile: got %d, want 0", got)
	}
	if got := *extractChain.Validators[0].Binding.StructuredOutputRepairAttempts; got != 3 {
		t.Fatalf("resolved validator repair attempts aliased profile: got %d, want 3", got)
	}
}

func TestResolvePipelineRejectsStructuredOutputRepairAttemptsOnDeterministicBindings(t *testing.T) {
	for _, test := range []struct {
		name   string
		mutate func(*PipelineProfile)
	}{
		{name: "module", mutate: func(profile *PipelineProfile) { profile.Input.StructuredOutputRepairAttempts = repairAttempts(1) }},
		{name: "validator", mutate: func(profile *PipelineProfile) {
			lane := profile.Artifacts["events"]
			lane.Extract.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "grounded", StructuredOutputRepairAttempts: repairAttempts(1)}}}
			profile.Artifacts["events"] = lane
		}},
	} {
		t.Run(test.name, func(t *testing.T) {
			profile := baselineProfile()
			test.mutate(&profile)
			_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
			if err == nil || !strings.Contains(err.Error(), "structured_output_repair_attempts") || !strings.Contains(err.Error(), "deterministic") {
				t.Fatalf("ResolvePipeline() error = %v, want deterministic repair-attempt rejection", err)
			}
		})
	}
}

func TestResolvePipelineLeavesPipelineRepairAttemptsOffDeterministicBindings(t *testing.T) {
	profile := baselineProfile()
	profile.StructuredOutputRepairAttempts = repairAttempts(2)

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if resolved.Input.StructuredOutputRepairAttempts != nil || resolved.Chunk.StructuredOutputRepairAttempts != nil || resolved.Output.StructuredOutputRepairAttempts != nil {
		t.Fatalf("deterministic pipeline inherited repair attempts: %#v", resolved)
	}
	lane := resolved.Steps[0].ArtifactLanes[0]
	if lane.Extract.StructuredOutputRepairAttempts != nil || lane.Merge.StructuredOutputRepairAttempts != nil || lane.Normalize.StructuredOutputRepairAttempts != nil {
		t.Fatalf("deterministic lane inherited repair attempts: %#v", lane)
	}
}

func TestResolvePipelineDigestUsesEffectiveStructuredOutputRepairAttempts(t *testing.T) {
	digests := make(map[string]string)
	for _, test := range []struct {
		name     string
		attempts *int
	}{
		{name: "prompt owned", attempts: nil},
		{name: "disabled", attempts: repairAttempts(0)},
		{name: "configured", attempts: repairAttempts(1)},
	} {
		t.Run(test.name, func(t *testing.T) {
			profile := llmProfilePipeline()
			profile.StructuredOutputRepairAttempts = test.attempts
			resolved, err := ResolvePipeline(profile, ResolveOptions{}, llmProfileCatalogWithoutValidatorChains(t))
			if err != nil {
				t.Fatalf("ResolvePipeline() error = %v, want nil", err)
			}
			digests[test.name] = resolved.Digest
		})
	}
	if digests["prompt owned"] == digests["disabled"] || digests["disabled"] == digests["configured"] || digests["prompt owned"] == digests["configured"] {
		t.Fatalf("digests = %#v, want distinct effective repair policies", digests)
	}
}

func TestResolvePipelineRecordsValidatorChains(t *testing.T) {
	catalog := newProfileCatalog(t)
	if err := catalog.ValidatorChains.Register(ValidatorChainMapping{
		Stage:      StageExtract,
		Module:     "event-extractor",
		Validators: []ModuleBinding{Binding("grounded")},
	}); err != nil {
		t.Fatalf("register validator chain: %v", err)
	}

	resolved, err := ResolvePipeline(PipelineProfile{
		ID:    "validated",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {Extract: Binding("event-extractor")},
		},
	}, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	if len(resolved.ValidatorChains) != 4 {
		t.Fatalf("len(ValidatorChains) = %d, want chunk plus lane extract/merge/normalize", len(resolved.ValidatorChains))
	}
	extractChain := findResolvedValidatorChain(resolved.ValidatorChains, StageExtract, "events", "event-extractor")
	if extractChain == nil {
		t.Fatal("extract validator chain not found")
	}
	if len(extractChain.Validators) != 1 {
		t.Fatalf("extract validators = %#v, want one validator", extractChain.Validators)
	}
	if extractChain.Validators[0].Binding.Module != "grounded" {
		t.Fatalf("extract validator key = %q, want grounded", extractChain.Validators[0].Binding.Module)
	}
	if extractChain.Validators[0].ExecutionClass != contracts.ExecutionClassDeterministic {
		t.Fatalf("extract validator execution class = %q, want deterministic", extractChain.Validators[0].ExecutionClass)
	}

	chunkChain := findResolvedValidatorChain(resolved.ValidatorChains, StageChunk, "", DefaultChunkModule)
	if chunkChain == nil {
		t.Fatal("chunk validator chain not found")
	}
	if len(chunkChain.Validators) != 0 {
		t.Fatalf("chunk validators = %#v, want explicit empty chain", chunkChain.Validators)
	}
}

func TestResolvePipelineRejectsUnknownDefaultValidator(t *testing.T) {
	catalog := newProfileCatalog(t)
	if err := catalog.ValidatorChains.Register(ValidatorChainMapping{
		Stage:      StageNormalize,
		Module:     DefaultNormalizeModule,
		Validators: []ModuleBinding{Binding("missing-validator")},
	}); err != nil {
		t.Fatalf("register validator chain: %v", err)
	}

	_, err := ResolvePipeline(PipelineProfile{
		ID:    "invalid-chain",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {Extract: Binding("event-extractor")},
		},
	}, ResolveOptions{}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want unknown validator error")
	}
	if !strings.Contains(err.Error(), "missing-validator") {
		t.Fatalf("ResolvePipeline() error = %q, want missing validator context", err.Error())
	}
}

func TestResolvePipelineValidatorOverrideReplacesDefaultChain(t *testing.T) {
	catalog := newProfileCatalog(t)
	registerProfileValidatorSpec(t, catalog, ValidatorSpec{Key: "second-validator", ExecutionClass: contracts.ExecutionClassLLMBacked})
	if err := catalog.ValidatorChains.Register(ValidatorChainMapping{
		Stage:      StageExtract,
		Module:     "event-extractor",
		Validators: []ModuleBinding{Binding("grounded")},
	}); err != nil {
		t.Fatalf("register validator chain: %v", err)
	}

	profile := PipelineProfile{
		ID:    "validated",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {
				Extract: ModuleBinding{
					Module: "event-extractor",
					Validators: ValidatorOverride{
						Set: true,
						Validators: []ModuleBinding{
							{Module: "second-validator", LLMProfile: "careful"},
							Binding("grounded"),
						},
					},
				},
			},
		},
	}

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	extractChain := findResolvedValidatorChain(resolved.ValidatorChains, StageExtract, "events", "event-extractor")
	if extractChain == nil {
		t.Fatal("extract validator chain not found")
	}
	if len(extractChain.Validators) != 2 {
		t.Fatalf("extract validators = %#v, want explicit two-validator override", extractChain.Validators)
	}
	if extractChain.Validators[0].Binding.Module != "second-validator" || extractChain.Validators[0].Binding.LLMProfile != "careful" {
		t.Fatalf("first validator = %#v, want explicit LLM-backed validator first", extractChain.Validators[0])
	}
	if extractChain.Validators[1].Binding.Module != "grounded" {
		t.Fatalf("second validator = %#v, want grounded second", extractChain.Validators[1])
	}
}

func TestResolvePipelineExplicitEmptyValidatorOverrideSuppressesDefaultChain(t *testing.T) {
	catalog := newProfileCatalog(t)
	if err := catalog.ValidatorChains.Register(ValidatorChainMapping{
		Stage:      StageExtract,
		Module:     "event-extractor",
		Validators: []ModuleBinding{Binding("grounded")},
	}); err != nil {
		t.Fatalf("register validator chain: %v", err)
	}

	profile := PipelineProfile{
		ID:    "validated",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {
				Extract: ModuleBinding{
					Module:     "event-extractor",
					Validators: ValidatorOverride{Set: true},
				},
			},
		},
	}

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	extractChain := findResolvedValidatorChain(resolved.ValidatorChains, StageExtract, "events", "event-extractor")
	if extractChain == nil {
		t.Fatal("extract validator chain not found")
	}
	if len(extractChain.Validators) != 0 {
		t.Fatalf("extract validators = %#v, want explicit empty override", extractChain.Validators)
	}
}

func TestResolvePipelineRejectsUnknownOverrideValidator(t *testing.T) {
	_, err := ResolvePipeline(PipelineProfile{
		ID:    "validated",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {
				Extract: ModuleBinding{
					Module: "event-extractor",
					Validators: ValidatorOverride{
						Set:        true,
						Validators: []ModuleBinding{Binding("missing-validator")},
					},
				},
			},
		},
	}, ResolveOptions{}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want unknown validator error")
	}
	if !strings.Contains(err.Error(), "missing-validator") {
		t.Fatalf("ResolvePipeline() error = %q, want missing validator context", err.Error())
	}
}

func TestResolvePipelineRejectsLLMProfileForDeterministicValidator(t *testing.T) {
	_, err := ResolvePipeline(PipelineProfile{
		ID:    "validated",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {
				Extract: ModuleBinding{
					Module: "event-extractor",
					Validators: ValidatorOverride{
						Set: true,
						Validators: []ModuleBinding{
							{Module: "grounded", LLMProfile: "careful"},
						},
					},
				},
			},
		},
	}, ResolveOptions{}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want deterministic validator profile error")
	}
	if !strings.Contains(err.Error(), "grounded") || !strings.Contains(err.Error(), "llm_profile") {
		t.Fatalf("ResolvePipeline() error = %q, want validator profile context", err.Error())
	}
}

func TestResolvePipelineSelectsOnlyRequestedLanes(t *testing.T) {
	profile := multiLaneProfile()
	resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{" summaries ", "events", "summaries"}}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	got := laneIDs(resolved.Steps[0].ArtifactLanes)
	want := []string{"events", "summaries"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("lane IDs = %#v, want %#v", got, want)
	}
}

func TestResolvePipelinePreservesOrderedStepsAndExpandsGeneratedBindings(t *testing.T) {
	catalog := newProfileCatalogWithOverrides(t,
		ModuleSpec{
			Key: "note-extractor", Stage: StageExtract, ArtifactKind: "test/notes",
			ExecutionClass: contracts.ExecutionClassDeterministic,
			Requires:       []string{"chunk"}, Provides: []string{"candidate"},
			ReferenceSlots: []contracts.ReferenceSlot{{Name: "npcs", AcceptedArtifactKinds: []contracts.ArtifactKind{"test/notes"}, AcceptedMediaTypes: []string{"application/json"}}},
		})
	resolved, err := ResolvePipeline(PipelineProfile{
		ID:    "ordered",
		Input: Binding("text"),
		Steps: []PipelineStepProfile{
			{ID: "produce", Artifacts: map[string]ArtifactLaneProfile{"npcs": {Extract: Binding("event-extractor")}}},
			{ID: "consume", References: map[string]ReferenceSource{"npcs": GeneratedReference("produce", "npcs")}, Artifacts: map[string]ArtifactLaneProfile{"events": {Extract: Binding("note-extractor")}}},
		},
	}, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if got := []string{resolved.Steps[0].ID, resolved.Steps[1].ID}; !reflect.DeepEqual(got, []string{"produce", "consume"}) {
		t.Fatalf("step order = %#v", got)
	}
	binding := resolved.Steps[1].ArtifactLanes[0].ExtractReferences.Bindings[0]
	if binding.Artifact == nil || binding.Artifact.Step != "produce" || binding.Artifact.Lane != "npcs" {
		t.Fatalf("generated binding = %#v", binding)
	}
	if len(resolved.AllArtifactLanes()) != 2 || resolved.Steps[1].ArtifactLanes[0].StepID != "consume" {
		t.Fatalf("resolved lane topology = %#v", resolved.Steps)
	}
}

func TestResolvePipelineRejectsGeneratedBindingOrderingAndKind(t *testing.T) {
	catalog := newProfileCatalogWithOverrides(t,
		ModuleSpec{Key: "note-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "npcs", AcceptedArtifactKinds: []contracts.ArtifactKind{"test/other"}}}},
	)
	_, err := ResolvePipeline(PipelineProfile{
		ID: "invalid-order", Input: Binding("text"), Steps: []PipelineStepProfile{
			{ID: "first", References: map[string]ReferenceSource{"npcs": GeneratedReference("later", "npcs")}, Artifacts: map[string]ArtifactLaneProfile{"events": {Extract: Binding("note-extractor")}}},
			{ID: "later", Artifacts: map[string]ArtifactLaneProfile{"npcs": {Extract: Binding("event-extractor")}}},
		},
	}, ResolveOptions{}, catalog)
	if err == nil || !strings.Contains(err.Error(), "earlier step") {
		t.Fatalf("ResolvePipeline() error = %v, want ordering failure", err)
	}
	_, err = ResolvePipeline(PipelineProfile{
		ID: "invalid-kind", Input: Binding("text"), Steps: []PipelineStepProfile{
			{ID: "produce", Artifacts: map[string]ArtifactLaneProfile{"npcs": {Extract: Binding("event-extractor")}}},
			{ID: "consume", References: map[string]ReferenceSource{"npcs": GeneratedReference("produce", "npcs")}, Artifacts: map[string]ArtifactLaneProfile{"events": {Extract: Binding("note-extractor")}}},
		},
	}, ResolveOptions{}, catalog)
	if err == nil || !strings.Contains(err.Error(), "does not accept artifact kind") {
		t.Fatalf("ResolvePipeline() error = %v, want generated kind failure", err)
	}
}

func TestResolvePipelineAppliesReferenceBindings(t *testing.T) {
	profile := multiLaneProfile()
	profile.References = ExternalReferenceMap(map[string]string{
		" roster ": " ./shared-roster.yml ",
	})
	lane := profile.Artifacts["events"]
	lane.References = ExternalReferenceMap(map[string]string{
		"roster": "./lane-roster.yml",
		" lore ": " ./lore.md ",
	})
	profile.Artifacts["events"] = lane

	catalog := newProfileCatalogWithOverride(t, ModuleSpec{
		Key:            "event-extractor",
		Stage:          StageExtract,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"chunk"},
		Provides:       []string{"candidate"},
		ReferenceSlots: []contracts.ReferenceSlot{
			{Name: "roster", Required: true},
			{Name: "lore"},
		},
	})
	resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events", "summaries"}}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	events := resolvedLane(t, resolved.Steps[0].ArtifactLanes, "events")
	if events.ExtractReferences.Stage != StageExtract || events.ExtractReferences.LaneID != "events" || events.ExtractReferences.Module != "event-extractor" {
		t.Fatalf("extract reference target = %#v, want event extractor target", events.ExtractReferences)
	}
	if events.NormalizeReferences.Stage != StageNormalize || events.NormalizeReferences.LaneID != "events" || events.NormalizeReferences.Module != DefaultNormalizeModule {
		t.Fatalf("normalize reference target = %#v, want event normalizer target", events.NormalizeReferences)
	}
	if events.MergeReferences.Stage != StageMerge || events.MergeReferences.LaneID != "events" || events.MergeReferences.Module != DefaultMergeModule {
		t.Fatalf("merge reference target = %#v, want event merger target", events.MergeReferences)
	}
	if resolved.ChunkReferences.Stage != StageChunk || resolved.ChunkReferences.Module != DefaultChunkModule {
		t.Fatalf("chunk reference target = %#v, want chunk target", resolved.ChunkReferences)
	}
	want := []ReferenceBinding{
		{LaneID: "events", SlotName: "lore", Source: "./lore.md", BindingSource: contracts.ReferenceBindingSourceConfig},
		{LaneID: "events", SlotName: "roster", Source: "./lane-roster.yml", BindingSource: contracts.ReferenceBindingSourceConfig},
	}
	if !reflect.DeepEqual(events.ExtractReferences.Bindings, want) {
		t.Fatalf("events references = %#v, want %#v", events.ExtractReferences.Bindings, want)
	}
	summaries := resolvedLane(t, resolved.Steps[0].ArtifactLanes, "summaries")
	if len(summaries.ExtractReferences.Bindings) != 0 {
		t.Fatalf("summaries references = %#v, want none", summaries.ExtractReferences.Bindings)
	}
}

func TestResolvePipelineAppliesPipelineReferenceDefaultToChunkTarget(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{"scene_guide": "./scenes.md"})
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "generic",
		Stage:          StageChunk,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"source"},
		Provides:       []string{"chunk"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "scene_guide"}},
	})

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	want := []ReferenceBinding{{SlotName: "scene_guide", Source: "./scenes.md", BindingSource: contracts.ReferenceBindingSourceConfig}}
	if !reflect.DeepEqual(resolved.ChunkReferences.Bindings, want) {
		t.Fatalf("chunk references = %#v, want %#v", resolved.ChunkReferences.Bindings, want)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 {
		t.Fatalf("extract references = %#v, want none", refs)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings; len(refs) != 0 {
		t.Fatalf("normalize references = %#v, want none", refs)
	}
}

func TestResolvePipelineAppliesPipelineReferenceDefaultToExtractorTarget(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{"roster": "./roster.yml"})
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "event-extractor",
		Stage:          StageExtract,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"chunk"},
		Provides:       []string{"candidate"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "roster"}},
	})

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	want := []ReferenceBinding{{LaneID: "events", SlotName: "roster", Source: "./roster.yml", BindingSource: contracts.ReferenceBindingSourceConfig}}
	if !reflect.DeepEqual(resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings, want) {
		t.Fatalf("extract references = %#v, want %#v", resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings, want)
	}
	if refs := resolved.ChunkReferences.Bindings; len(refs) != 0 {
		t.Fatalf("chunk references = %#v, want none", refs)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings; len(refs) != 0 {
		t.Fatalf("normalize references = %#v, want none", refs)
	}
}

func TestResolvePipelineAppliesPipelineReferenceDefaultToNormalizerTarget(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{"normalization_notes": "./normalize.md"})
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "noop",
		Stage:          StageNormalize,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"merged"},
		Provides:       []string{"normalized"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "normalization_notes"}},
	})

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	want := []ReferenceBinding{{LaneID: "events", SlotName: "normalization_notes", Source: "./normalize.md", BindingSource: contracts.ReferenceBindingSourceConfig}}
	if !reflect.DeepEqual(resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings, want) {
		t.Fatalf("normalize references = %#v, want %#v", resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings, want)
	}
	if refs := resolved.ChunkReferences.Bindings; len(refs) != 0 {
		t.Fatalf("chunk references = %#v, want none", refs)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 {
		t.Fatalf("extract references = %#v, want none", refs)
	}
}

func TestResolvePipelineAppliesPipelineReferenceDefaultToMergeTarget(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{"merge_notes": "./merge.md"})
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "appendorder",
		Stage:          StageMerge,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"candidate"},
		Provides:       []string{"merged"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "merge_notes"}},
	})

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	want := []ReferenceBinding{{LaneID: "events", SlotName: "merge_notes", Source: "./merge.md", BindingSource: contracts.ReferenceBindingSourceConfig}}
	if !reflect.DeepEqual(resolved.Steps[0].ArtifactLanes[0].MergeReferences.Bindings, want) {
		t.Fatalf("merge references = %#v, want %#v", resolved.Steps[0].ArtifactLanes[0].MergeReferences.Bindings, want)
	}
	if refs := resolved.ChunkReferences.Bindings; len(refs) != 0 {
		t.Fatalf("chunk references = %#v, want none", refs)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 {
		t.Fatalf("extract references = %#v, want none", refs)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings; len(refs) != 0 {
		t.Fatalf("normalize references = %#v, want none", refs)
	}
}

func TestResolvePipelineAppliesOnePipelineReferenceDefaultToMultipleTargets(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{"context": "./context.md"})
	catalog := newProfileCatalogWithOverrides(t,
		ModuleSpec{Key: "generic", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"source"}, Provides: []string{"chunk"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "context"}}},
		ModuleSpec{Key: "event-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"chunk"}, Provides: []string{"candidate"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "context"}}},
		ModuleSpec{Key: "appendorder", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"candidate"}, Provides: []string{"merged"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "context"}}},
		ModuleSpec{Key: "noop", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "context"}}},
	)

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	assertBindingSource(t, resolved.ChunkReferences.Bindings, "context", "./context.md")
	assertBindingSource(t, resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings, "context", "./context.md")
	assertBindingSource(t, resolved.Steps[0].ArtifactLanes[0].MergeReferences.Bindings, "context", "./context.md")
	assertBindingSource(t, resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings, "context", "./context.md")
}

func TestResolvePipelineAllowsPipelineReferenceDeclaredOnlyByUnselectedLane(t *testing.T) {
	profile := multiLaneProfile()
	profile.References = ExternalReferenceMap(map[string]string{"notes_context": "./notes.md"})
	catalog := newProfileCatalogWithOverride(t, ModuleSpec{
		Key:            "note-extractor",
		Stage:          StageExtract,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"chunk"},
		Provides:       []string{"candidate"},
		ReferenceSlots: []contracts.ReferenceSlot{
			{Name: "notes_context"},
		},
	})

	resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 {
		t.Fatalf("selected lane references = %#v, want none", refs)
	}
}

func TestResolvePipelineAllowsPipelineReferenceDeclaredOnlyByUnselectedNormalizer(t *testing.T) {
	profile := multiLaneProfile()
	profile.References = ExternalReferenceMap(map[string]string{"notes_context": "./notes.md"})
	lane := profile.Artifacts["notes"]
	lane.Normalize = Binding("note-normalizer")
	profile.Artifacts["notes"] = lane
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "note-normalizer",
		Stage:          StageNormalize,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"merged"},
		Provides:       []string{"normalized"},
		ReferenceSlots: []contracts.ReferenceSlot{
			{Name: "notes_context"},
		},
	})

	resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 {
		t.Fatalf("selected extract references = %#v, want none", refs)
	}
	if refs := resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings; len(refs) != 0 {
		t.Fatalf("selected normalize references = %#v, want none", refs)
	}
}

func TestResolvePipelineRejectsPipelineReferenceNotDeclaredByAnyLane(t *testing.T) {
	profile := multiLaneProfile()
	profile.References = ExternalReferenceMap(map[string]string{"missing": "./missing.md"})

	_, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "multi", "reference slot", "missing", "not declared")
}

func TestResolvePipelineRejectsUndeclaredReferenceSlot(t *testing.T) {
	profile := baselineProfile()
	lane := profile.Artifacts["events"]
	lane.References = ExternalReferenceMap(map[string]string{"missing": "./missing.yml"})
	profile.Artifacts["events"] = lane

	_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "events", "missing", "not declared")
}

func TestResolvePipelineRejectsExtractLocalReferenceDeclaredOnlyByNormalizer(t *testing.T) {
	profile := baselineProfile()
	lane := profile.Artifacts["events"]
	lane.Extract.References = ExternalReferenceMap(map[string]string{"normalization_notes": "./normalize.md"})
	profile.Artifacts["events"] = lane
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "noop",
		Stage:          StageNormalize,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"merged"},
		Provides:       []string{"normalized"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "normalization_notes"}},
	})

	_, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "baseline", "events", "extract", "event-extractor", "normalization_notes", "not declared")
}

func TestResolvePipelineRejectsMergeLocalReferenceDeclaredOnlyByNormalizer(t *testing.T) {
	profile := baselineProfile()
	lane := profile.Artifacts["events"]
	lane.Merge.References = ExternalReferenceMap(map[string]string{"normalization_notes": "./normalize.md"})
	profile.Artifacts["events"] = lane
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "noop",
		Stage:          StageNormalize,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"merged"},
		Provides:       []string{"normalized"},
		ReferenceSlots: []contracts.ReferenceSlot{
			{Name: "normalization_notes"},
		},
	})

	_, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "merge", "normalization_notes", "not declared")
}

func TestResolvePipelineRejectsNormalizeLocalReferenceDeclaredOnlyByExtractor(t *testing.T) {
	profile := baselineProfile()
	lane := profile.Artifacts["events"]
	lane.Normalize.References = ExternalReferenceMap(map[string]string{"roster": "./roster.yml"})
	profile.Artifacts["events"] = lane
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "event-extractor",
		Stage:          StageExtract,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"chunk"},
		Provides:       []string{"candidate"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "roster"}},
	})

	_, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "baseline", "events", "normalize", "noop", "roster", "not declared")
}

func TestResolvePipelineRequiresBoundChunkReference(t *testing.T) {
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "generic",
		Stage:          StageChunk,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"source"},
		Provides:       []string{"chunk"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "scene_guide", Required: true}},
	})

	_, err := ResolvePipeline(baselineProfile(), ResolveOptions{}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "baseline", "chunk", "generic", "required", "scene_guide", "not bound")
}

func TestResolvePipelineRequiresBoundNormalizeReference(t *testing.T) {
	catalog := newProfileCatalogWithOverrides(t, ModuleSpec{
		Key:            "noop",
		Stage:          StageNormalize,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"merged"},
		Provides:       []string{"normalized"},
		ReferenceSlots: []contracts.ReferenceSlot{{Name: "normalization_notes", Required: true}},
	})

	_, err := ResolvePipeline(baselineProfile(), ResolveOptions{}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "baseline", "events", "normalize", "noop", "required", "normalization_notes", "not bound")
}

func TestResolvePipelineLocalReferencesOverridePipelineDefaultsForEligibleTargets(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{
		"context":             "./shared-context.md",
		"roster":              "./shared-roster.yml",
		"normalization_notes": "./shared-normalize.md",
	})
	profile.Chunk.References = ExternalReferenceMap(map[string]string{"context": "./chunk-context.md"})
	lane := profile.Artifacts["events"]
	lane.Extract.References = ExternalReferenceMap(map[string]string{"roster": "./extract-roster.yml"})
	lane.Normalize.References = ExternalReferenceMap(map[string]string{"normalization_notes": "./local-normalize.md"})
	profile.Artifacts["events"] = lane
	catalog := newProfileCatalogWithOverrides(t,
		ModuleSpec{Key: "generic", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"source"}, Provides: []string{"chunk"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "context"}}},
		ModuleSpec{Key: "event-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"chunk"}, Provides: []string{"candidate"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "roster"}}},
		ModuleSpec{Key: "noop", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"merged"}, Provides: []string{"normalized"}, ReferenceSlots: []contracts.ReferenceSlot{{Name: "normalization_notes"}}},
	)

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	assertBindingSource(t, resolved.ChunkReferences.Bindings, "context", "./chunk-context.md")
	assertBindingSource(t, resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings, "roster", "./extract-roster.yml")
	assertBindingSource(t, resolved.Steps[0].ArtifactLanes[0].NormalizeReferences.Bindings, "normalization_notes", "./local-normalize.md")
}

func TestResolvePipelineRequiresBoundReferenceSlotsForSelectedLanes(t *testing.T) {
	catalog := newProfileCatalogWithOverride(t, ModuleSpec{
		Key:            "event-extractor",
		Stage:          StageExtract,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"chunk"},
		Provides:       []string{"candidate"},
		ReferenceSlots: []contracts.ReferenceSlot{
			{Name: "roster", Required: true},
		},
	})

	if _, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{"notes"}}, catalog); err != nil {
		t.Fatalf("ResolvePipeline(unselected required slot) error = %v, want nil", err)
	}

	_, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{"events"}}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline(selected required slot) error = nil, want error")
	}
	assertErrorContains(t, err, "events", "required", "roster", "not bound")
}

func TestResolvePipelineReferenceUnbindCanLeaveRequiredSlotMissing(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{"roster": "./roster.yml"})
	catalog := newProfileCatalogWithOverride(t, ModuleSpec{
		Key:            "event-extractor",
		Stage:          StageExtract,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		Requires:       []string{"chunk"},
		Provides:       []string{"candidate"},
		ReferenceSlots: []contracts.ReferenceSlot{
			{Name: "roster", Required: true},
		},
	})

	_, err := ResolvePipeline(profile, ResolveOptions{
		ReferenceUnbinds: []ReferenceUnbind{{LaneID: "events", SlotName: "roster"}},
	}, catalog)
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "events", "required", "roster", "not bound")
}

func TestResolvePipelineUsesReferenceSlotsFromSpecWithoutConstructingExtractor(t *testing.T) {
	profile := baselineProfile()
	profile.References = ExternalReferenceMap(map[string]string{"roster": "./roster.yml"})
	catalog := emptyProfileCatalog()
	mustRegisterArtifactCodec(t, catalog.ArtifactCodecs, notesCodec())
	for _, spec := range defaultProfileSpecs() {
		if spec.Key != "event-extractor" {
			registerProfileSpecs(t, catalog, spec)
		}
	}
	if err := RegisterExtractor[codecNotes](catalog.Extractors, ModuleSpec{
		Key:            "event-extractor",
		Stage:          StageExtract,
		ExecutionClass: contracts.ExecutionClassDeterministic,
		ArtifactKind:   "test/notes",
		Requires:       []string{"chunk"},
		Provides:       []string{"candidate"},
		ReferenceSlots: []contracts.ReferenceSlot{
			{Name: "roster", Required: true},
		},
	}, func() (contracts.Extractor[codecNotes], error) {
		return nil, errors.New("constructor should not run")
	}); err != nil {
		t.Fatalf("RegisterWithSpec() error = %v, want nil", err)
	}

	resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	if got := resolved.Steps[0].ArtifactLanes[0].ExtractReferences.Bindings[0].Source; got != "./roster.yml" {
		t.Fatalf("reference source = %q, want ./roster.yml", got)
	}
}

func TestResolvePipelineRejectsUnknownOnlyLane(t *testing.T) {
	_, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{"missing"}}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "pipeline", "missing", "not declared")
}

func TestResolvePipelineRejectsEmptyOnlyLane(t *testing.T) {
	_, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{" \t"}}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "pipeline", "artifact lane", "empty")
}

func TestResolvePipelineRejectsEmptyArtifactSet(t *testing.T) {
	_, err := ResolvePipeline(PipelineProfile{
		ID:        "empty",
		Input:     Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{},
	}, ResolveOptions{}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "empty", "artifact lane")
}

func TestResolvePipelineRejectsEmptyPipelineID(t *testing.T) {
	_, err := ResolvePipeline(PipelineProfile{
		ID:    " ",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {Extract: Binding("event-extractor")},
		},
	}, ResolveOptions{}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "pipeline id", "empty")
}

func TestResolvePipelineRejectsMissingInput(t *testing.T) {
	_, err := ResolvePipeline(PipelineProfile{
		ID: "missing-input",
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {Extract: Binding("event-extractor")},
		},
	}, ResolveOptions{}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "missing-input", "input", "empty")
}

func TestResolvePipelineRejectsUnknownModuleKeys(t *testing.T) {
	tests := []struct {
		name    string
		profile PipelineProfile
		want    []string
	}{
		{
			name: "input",
			profile: PipelineProfile{
				ID:    "unknown-input",
				Input: Binding("missing-input"),
				Artifacts: map[string]ArtifactLaneProfile{
					"events": {Extract: Binding("event-extractor")},
				},
			},
			want: []string{"unknown-input", "input", "missing-input"},
		},
		{
			name: "chunk",
			profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
				profile.Chunk = Binding("missing-chunk")
				return profile
			}),
			want: []string{"baseline", "chunk", "missing-chunk"},
		},
		{
			name: "extract",
			profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
				lane := profile.Artifacts["events"]
				lane.Extract = Binding("missing-extractor")
				profile.Artifacts["events"] = lane
				return profile
			}),
			want: []string{"baseline", "events", "extract", "missing-extractor"},
		},
		{
			name: "merge",
			profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
				lane := profile.Artifacts["events"]
				lane.Merge = Binding("missing-merge")
				profile.Artifacts["events"] = lane
				return profile
			}),
			want: []string{"baseline", "events", "merge", "missing-merge"},
		},
		{
			name: "normalize",
			profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
				lane := profile.Artifacts["events"]
				lane.Normalize = Binding("missing-normalize")
				profile.Artifacts["events"] = lane
				return profile
			}),
			want: []string{"baseline", "events", "normalize", "missing-normalize"},
		},
		{
			name: "output",
			profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
				profile.Output = Binding("missing-output")
				return profile
			}),
			want: []string{"baseline", "output", "missing-output"},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			_, err := ResolvePipeline(test.profile, ResolveOptions{}, newProfileCatalog(t))
			if err == nil {
				t.Fatal("ResolvePipeline() error = nil, want error")
			}
			assertErrorContains(t, err, test.want...)
		})
	}
}

func TestResolvePipelineRejectsMissingCapabilities(t *testing.T) {
	tests := []struct {
		name string
		spec ModuleSpec
		want []string
	}{
		{
			name: "input",
			spec: ModuleSpec{Key: "text", Stage: StageInput, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"raw"}},
			want: []string{"baseline", "input", "text", "raw"},
		},
		{
			name: "chunk",
			spec: ModuleSpec{Key: "generic", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"missing"}},
			want: []string{"baseline", "chunk", "generic", "missing"},
		},
		{
			name: "extract",
			spec: ModuleSpec{Key: "event-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"missing"}},
			want: []string{"baseline", "events", "extract", "event-extractor", "missing"},
		},
		{
			name: "merge",
			spec: ModuleSpec{Key: "appendorder", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"missing"}},
			want: []string{"baseline", "events", "merge", "appendorder", "missing"},
		},
		{
			name: "normalize",
			spec: ModuleSpec{Key: "noop", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"missing"}},
			want: []string{"baseline", "events", "normalize", "noop", "missing"},
		},
		{
			name: "output",
			spec: ModuleSpec{Key: "json", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"missing"}},
			want: []string{"baseline", "output", "json", "missing"},
		},
	}

	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			catalog := newProfileCatalogWithOverride(t, test.spec)
			profile := baselineProfile()

			_, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
			if err == nil {
				t.Fatal("ResolvePipeline() error = nil, want error")
			}
			assertErrorContains(t, err, test.want...)
		})
	}
}

func TestResolvePipelineRejectsConfiguredValidators(t *testing.T) {
	profile := baselineProfile()
	lane := profile.Artifacts["events"]
	lane.Validators = []ModuleBinding{Binding("grounded")}
	profile.Artifacts["events"] = lane

	_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
	if err == nil {
		t.Fatal("ResolvePipeline() error = nil, want error")
	}
	assertErrorContains(t, err, "baseline", "events", "validators", "extract.validators")
}

func TestResolvePipelineOrdersLanesDeterministically(t *testing.T) {
	resolved, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}

	got := laneIDs(resolved.Steps[0].ArtifactLanes)
	want := []string{"events", "notes", "summaries"}
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("lane IDs = %#v, want %#v", got, want)
	}
}

func TestResolvePipelineDigestIsDeterministicForEquivalentMaps(t *testing.T) {
	left := PipelineProfile{
		ID:     "digest",
		Input:  Binding("text"),
		Output: Binding("json"),
		Chunk:  ModuleBinding{Module: "generic", Options: map[string]any{"b": 2, "a": 1}},
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {Extract: Binding("event-extractor")},
			"notes":  {Extract: Binding("note-extractor")},
		},
	}
	right := PipelineProfile{
		ID:     "digest",
		Input:  Binding("text"),
		Output: Binding("json"),
		Chunk:  ModuleBinding{Module: "generic", Options: map[string]any{"a": 1, "b": 2}},
		Artifacts: map[string]ArtifactLaneProfile{
			"notes":  {Extract: Binding("note-extractor")},
			"events": {Extract: Binding("event-extractor")},
		},
	}

	leftResolved, err := ResolvePipeline(left, ResolveOptions{}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline(left) error = %v, want nil", err)
	}
	rightResolved, err := ResolvePipeline(right, ResolveOptions{}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline(right) error = %v, want nil", err)
	}

	if leftResolved.Digest != rightResolved.Digest {
		t.Fatalf("digests differ for equivalent profiles: %q != %q", leftResolved.Digest, rightResolved.Digest)
	}
}

func TestResolvePipelineDigestChangesWhenBindingChanges(t *testing.T) {
	left := baselineProfile()
	right := baselineProfile()
	right.Chunk = Binding("window")
	catalog := newProfileCatalog(t)
	registerProfileSpecs(t, catalog, ModuleSpec{Key: "window", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"source"}, Provides: []string{"chunk"}})

	leftResolved, err := ResolvePipeline(left, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline(left) error = %v, want nil", err)
	}
	rightResolved, err := ResolvePipeline(right, ResolveOptions{}, catalog)
	if err != nil {
		t.Fatalf("ResolvePipeline(right) error = %v, want nil", err)
	}

	if leftResolved.Digest == rightResolved.Digest {
		t.Fatalf("digest = %q for both profiles, want changed digest", leftResolved.Digest)
	}
}

func TestResolvePipelineDigestIncludesEffectiveValidatorChain(t *testing.T) {
	resolve := func(t *testing.T, validators []ModuleBinding, explicitEmpty bool) ResolvedPipeline {
		t.Helper()
		catalog := newProfileCatalog(t)
		registerProfileValidatorSpec(t, catalog, ValidatorSpec{Key: "second-validator", ExecutionClass: contracts.ExecutionClassDeterministic})
		if len(validators) > 0 {
			if err := catalog.ValidatorChains.Register(ValidatorChainMapping{
				Stage:      StageExtract,
				Module:     "event-extractor",
				Validators: validators,
			}); err != nil {
				t.Fatalf("register validator chain: %v", err)
			}
		}

		profile := baselineProfile()
		if explicitEmpty {
			lane := profile.Artifacts["events"]
			lane.Extract.Validators = ValidatorOverride{Set: true}
			profile.Artifacts["events"] = lane
		}
		resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
		if err != nil {
			t.Fatalf("ResolvePipeline() error = %v, want nil", err)
		}
		return resolved
	}

	inherited := resolve(t, []ModuleBinding{Binding("grounded"), Binding("second-validator")}, false)
	repeated := resolve(t, []ModuleBinding{Binding("grounded"), Binding("second-validator")}, false)
	if inherited.Digest != repeated.Digest {
		t.Fatalf("same resolved validator policy produced digests %q and %q", inherited.Digest, repeated.Digest)
	}

	tests := []struct {
		name  string
		left  ResolvedPipeline
		right ResolvedPipeline
	}{
		{
			name:  "different default",
			left:  resolve(t, []ModuleBinding{Binding("grounded")}, false),
			right: resolve(t, []ModuleBinding{Binding("second-validator")}, false),
		},
		{
			name:  "added validator",
			left:  resolve(t, nil, false),
			right: resolve(t, []ModuleBinding{Binding("grounded")}, false),
		},
		{
			name:  "removed validator",
			left:  resolve(t, []ModuleBinding{Binding("grounded")}, false),
			right: resolve(t, nil, false),
		},
		{
			name:  "reordered chain",
			left:  inherited,
			right: resolve(t, []ModuleBinding{Binding("second-validator"), Binding("grounded")}, false),
		},
		{
			name:  "explicit empty",
			left:  inherited,
			right: resolve(t, []ModuleBinding{Binding("grounded"), Binding("second-validator")}, true),
		},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			if tc.left.Digest == tc.right.Digest {
				t.Fatalf("digest = %q for both validator policies, want changed digest", tc.left.Digest)
			}
		})
	}
}

func TestResolvedPipelineDigestIncludesCompleteValidatorPolicy(t *testing.T) {
	base := validatorDigestFixture()
	baseDigest, err := resolvedPipelineDigest(base)
	if err != nil {
		t.Fatalf("resolvedPipelineDigest(base) error = %v, want nil", err)
	}

	tests := []struct {
		name   string
		mutate func(*ResolvedPipeline)
	}{
		{name: "stage", mutate: func(value *ResolvedPipeline) { value.ValidatorChains[0].Stage = StageMerge }},
		{name: "lane", mutate: func(value *ResolvedPipeline) { value.ValidatorChains[0].LaneID = "other" }},
		{name: "owner module", mutate: func(value *ResolvedPipeline) { value.ValidatorChains[0].ModuleKey = "other-extractor" }},
		{name: "validator order", mutate: func(value *ResolvedPipeline) {
			value.ValidatorChains[0].Validators[0], value.ValidatorChains[0].Validators[1] = value.ValidatorChains[0].Validators[1], value.ValidatorChains[0].Validators[0]
		}},
		{name: "validator module", mutate: func(value *ResolvedPipeline) {
			value.ValidatorChains[0].Validators[0].Binding.Module = "other-validator"
		}},
		{name: "llm profile", mutate: func(value *ResolvedPipeline) { value.ValidatorChains[0].Validators[0].Binding.LLMProfile = "fast" }},
		{name: "retries", mutate: func(value *ResolvedPipeline) { value.ValidatorChains[0].Validators[0].Binding.Retries++ }},
		{name: "options", mutate: func(value *ResolvedPipeline) {
			value.ValidatorChains[0].Validators[0].Binding.Options["threshold"] = 0.75
		}},
		{name: "references", mutate: func(value *ResolvedPipeline) {
			value.ValidatorChains[0].Validators[0].Binding.References["rules"] = ExternalReference("other.md")
		}},
		{name: "execution class", mutate: func(value *ResolvedPipeline) {
			value.ValidatorChains[0].Validators[0].ExecutionClass = contracts.ExecutionClassDeterministic
		}},
		{name: "target", mutate: func(value *ResolvedPipeline) {
			value.ValidatorChains[0].Validators[0].Target = ValidatorTargetSerialized
		}},
		{name: "artifact kind", mutate: func(value *ResolvedPipeline) { value.ValidatorChains[0].Validators[0].ArtifactKind = "test/other" }},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			changed := validatorDigestFixture()
			tc.mutate(&changed)
			digest, err := resolvedPipelineDigest(changed)
			if err != nil {
				t.Fatalf("resolvedPipelineDigest(changed) error = %v, want nil", err)
			}
			if digest == baseDigest {
				t.Fatalf("digest = %q after %s change, want different digest", digest, tc.name)
			}
		})
	}
}

func TestResolvedPipelineDigestCanonicalizesValidatorBindingMaps(t *testing.T) {
	left := validatorDigestFixture()
	right := validatorDigestFixture()
	right.ValidatorChains[0].Validators[0].Binding.Options = map[string]any{}
	right.ValidatorChains[0].Validators[0].Binding.Options["threshold"] = 0.5
	right.ValidatorChains[0].Validators[0].Binding.Options["mode"] = "strict"
	right.ValidatorChains[0].Validators[0].Binding.References = ExternalReferenceMap(map[string]string{})
	right.ValidatorChains[0].Validators[0].Binding.References["examples"] = ExternalReference("examples.md")
	right.ValidatorChains[0].Validators[0].Binding.References["rules"] = ExternalReference("rules.md")

	leftDigest, err := resolvedPipelineDigest(left)
	if err != nil {
		t.Fatalf("resolvedPipelineDigest(left) error = %v, want nil", err)
	}
	rightDigest, err := resolvedPipelineDigest(right)
	if err != nil {
		t.Fatalf("resolvedPipelineDigest(right) error = %v, want nil", err)
	}
	if leftDigest != rightDigest {
		t.Fatalf("equivalent validator maps produced digests %q and %q", leftDigest, rightDigest)
	}
}

func validatorDigestFixture() ResolvedPipeline {
	return ResolvedPipeline{
		ID: "validator-policy",
		ValidatorChains: []ResolvedValidatorChain{{
			Stage:     StageExtract,
			LaneID:    "events",
			ModuleKey: "event-extractor",
			Validators: []ResolvedValidator{
				{
					Binding: ModuleBinding{
						Module:     "semantic-validator",
						LLMProfile: "careful",
						Retries:    2,
						Options:    map[string]any{"mode": "strict", "threshold": 0.5},
						References: ExternalReferenceMap(map[string]string{"rules": "rules.md", "examples": "examples.md"}),
					},
					ExecutionClass: contracts.ExecutionClassLLMBacked,
					Target:         ValidatorTargetTyped,
					ArtifactKind:   "test/notes",
				},
				{
					Binding:        Binding("grounded"),
					ExecutionClass: contracts.ExecutionClassDeterministic,
					Target:         ValidatorTargetTyped,
					ArtifactKind:   "test/notes",
				},
			},
		}},
	}
}

func TestBindingTrimsModuleAndLeavesResolutionFieldsEmpty(t *testing.T) {
	binding := Binding(" module ")
	if binding.Module != "module" {
		t.Fatalf("Module = %q, want module", binding.Module)
	}
	if binding.LLMProfile != "" {
		t.Fatalf("LLMProfile = %q, want empty", binding.LLMProfile)
	}
	if binding.Options != nil {
		t.Fatalf("Options = %#v, want nil", binding.Options)
	}
}

func TestResolvedPipelineDigestExcludesDigestField(t *testing.T) {
	resolved, err := ResolvePipeline(baselineProfile(), ResolveOptions{}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	changed := resolved
	changed.Digest = "sha256:changed"

	leftDigest, err := resolvedPipelineDigest(resolved)
	if err != nil {
		t.Fatalf("resolvedPipelineDigest(resolved) error = %v, want nil", err)
	}
	rightDigest, err := resolvedPipelineDigest(changed)
	if err != nil {
		t.Fatalf("resolvedPipelineDigest(changed) error = %v, want nil", err)
	}
	if leftDigest != rightDigest {
		t.Fatalf("digest with changed digest field = %q, want %q", rightDigest, leftDigest)
	}
}

func baselineProfile() PipelineProfile {
	return PipelineProfile{
		ID:    "baseline",
		Input: Binding("text"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {Extract: Binding("event-extractor")},
		},
	}
}

func multiLaneProfile() PipelineProfile {
	profile := baselineProfile()
	profile.ID = "multi"
	profile.Artifacts = map[string]ArtifactLaneProfile{
		"summaries": {Extract: Binding("note-extractor")},
		"events":    {Extract: Binding("event-extractor")},
		"notes":     {Extract: Binding("note-extractor")},
	}
	return profile
}

func withProfileChange(change func(PipelineProfile) PipelineProfile) PipelineProfile {
	return change(baselineProfile())
}

func laneIDs(lanes []ResolvedArtifactLane) []string {
	ids := make([]string, 0, len(lanes))
	for _, lane := range lanes {
		ids = append(ids, lane.ID)
	}
	return ids
}

func resolvedLane(t *testing.T, lanes []ResolvedArtifactLane, laneID string) ResolvedArtifactLane {
	t.Helper()
	for _, lane := range lanes {
		if lane.ID == laneID {
			return lane
		}
	}
	t.Fatalf("lane %q not found in %#v", laneID, laneIDs(lanes))
	return ResolvedArtifactLane{}
}

func assertErrorContains(t *testing.T, err error, values ...string) {
	t.Helper()

	message := err.Error()
	for _, value := range values {
		if !strings.Contains(message, value) {
			t.Fatalf("error = %q, want substring %q", message, value)
		}
	}
}

func assertBindingSource(t *testing.T, bindings []ReferenceBinding, slotName string, source string) {
	t.Helper()

	for _, binding := range bindings {
		if binding.SlotName != slotName {
			continue
		}
		if binding.Source != source {
			t.Fatalf("binding %q source = %q, want %q in %#v", slotName, binding.Source, source, bindings)
		}
		return
	}
	t.Fatalf("binding %q not found in %#v", slotName, bindings)
}

func findResolvedValidatorChain(chains []ResolvedValidatorChain, stage ModuleStage, laneID string, module string) *ResolvedValidatorChain {
	for i := range chains {
		if chains[i].Stage == stage && chains[i].LaneID == laneID && chains[i].ModuleKey == module {
			return &chains[i]
		}
	}
	return nil
}

func newProfileCatalog(t *testing.T) ModuleCatalog {
	t.Helper()

	catalog := emptyProfileCatalog()
	mustRegisterArtifactCodec(t, catalog.ArtifactCodecs, notesCodec())
	registerProfileSpecs(t, catalog, defaultProfileSpecs()...)
	return catalog
}

func newProfileCatalogWithOverride(t *testing.T, override ModuleSpec) ModuleCatalog {
	t.Helper()

	return newProfileCatalogWithOverrides(t, override)
}

func newProfileCatalogWithOverrides(t *testing.T, overrides ...ModuleSpec) ModuleCatalog {
	t.Helper()

	specs := defaultProfileSpecs()
	for _, override := range overrides {
		if override.ArtifactKind == "" && (override.Stage == StageExtract || override.Stage == StageMerge || override.Stage == StageNormalize) {
			override.ArtifactKind = "test/notes"
		}
		replaced := false
		for index, spec := range specs {
			if spec.Stage == override.Stage && spec.Key == override.Key {
				specs[index] = override
				replaced = true
				break
			}
		}
		if !replaced {
			specs = append(specs, override)
		}
	}

	catalog := emptyProfileCatalog()
	mustRegisterArtifactCodec(t, catalog.ArtifactCodecs, notesCodec())
	registerProfileSpecs(t, catalog, specs...)
	return catalog
}

func emptyProfileCatalog() ModuleCatalog {
	return ModuleCatalog{
		Inputs:          NewInputAdapterRegistry(),
		Chunkers:        NewChunkerRegistry(),
		ArtifactCodecs:  NewArtifactCodecRegistry(),
		Extractors:      NewExtractorRegistry(),
		Mergers:         NewMergerRegistry(),
		Normalizers:     NewNormalizerRegistry(),
		Validators:      NewValidatorRegistry(),
		ValidatorChains: NewValidatorChainRegistry(),
		Outputs:         NewOutputEncoderRegistry(),
	}
}

func defaultProfileSpecs() []ModuleSpec {
	return []ModuleSpec{
		ModuleSpec{Key: "text", Stage: StageInput, ExecutionClass: contracts.ExecutionClassDeterministic, Provides: []string{"source"}},
		ModuleSpec{Key: "generic", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"source"}, Provides: []string{"chunk"}},
		ModuleSpec{Key: "event-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}},
		ModuleSpec{Key: "note-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}},
		ModuleSpec{Key: "appendorder", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes", Requires: []string{"candidate"}, Provides: []string{"merged"}},
		ModuleSpec{Key: "noop", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassDeterministic, ArtifactKind: "test/notes", Requires: []string{"merged"}, Provides: []string{"normalized"}},
		ModuleSpec{Key: "grounded", Stage: StageValidate, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"normalized"}, Provides: []string{"validated"}},
		ModuleSpec{Key: "json", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassDeterministic, Requires: []string{"normalized"}, Provides: []string{"encoded"}},
	}
}

func llmProfileCatalog(t *testing.T) ModuleCatalog {
	t.Helper()
	catalog := newProfileCatalogWithOverrides(t,
		ModuleSpec{Key: "llm-input", Stage: StageInput, ExecutionClass: contracts.ExecutionClassLLMBacked, Provides: []string{"source"}},
		ModuleSpec{Key: "llm-chunk", Stage: StageChunk, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"source"}, Provides: []string{"chunk"}},
		ModuleSpec{Key: "llm-extractor", Stage: StageExtract, ExecutionClass: contracts.ExecutionClassLLMBacked, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}},
		ModuleSpec{Key: "llm-merge", Stage: StageMerge, ExecutionClass: contracts.ExecutionClassLLMBacked, ArtifactKind: "test/notes", Requires: []string{"candidate"}, Provides: []string{"merged"}},
		ModuleSpec{Key: "llm-normalize", Stage: StageNormalize, ExecutionClass: contracts.ExecutionClassLLMBacked, ArtifactKind: "test/notes", Requires: []string{"merged"}, Provides: []string{"normalized"}},
		ModuleSpec{Key: "llm-output", Stage: StageOutput, ExecutionClass: contracts.ExecutionClassLLMBacked, Requires: []string{"normalized"}, Provides: []string{"encoded"}},
	)
	if err := RegisterChunkValidator(catalog.Validators, ValidatorSpec{Key: "llm-chunk-validator", ExecutionClass: contracts.ExecutionClassLLMBacked}, func() (contracts.ChunkValidator, error) {
		return typedTestChunkValidator{key: "llm-chunk-validator"}, nil
	}); err != nil {
		t.Fatalf("register chunk validator: %v", err)
	}
	registerProfileValidatorSpec(t, catalog, ValidatorSpec{Key: "llm-validator", ExecutionClass: contracts.ExecutionClassLLMBacked})
	for _, mapping := range []ValidatorChainMapping{
		{Stage: StageChunk, Module: "llm-chunk", Validators: []ModuleBinding{Binding("llm-chunk-validator")}},
		{Stage: StageExtract, Module: "llm-extractor", Validators: []ModuleBinding{Binding("llm-validator")}},
		{Stage: StageMerge, Module: "llm-merge", Validators: []ModuleBinding{Binding("llm-validator")}},
		{Stage: StageNormalize, Module: "llm-normalize", Validators: []ModuleBinding{Binding("llm-validator")}},
	} {
		if err := catalog.ValidatorChains.Register(mapping); err != nil {
			t.Fatalf("register validator chain %#v: %v", mapping, err)
		}
	}
	return catalog
}

func llmProfileCatalogWithoutValidatorChains(t *testing.T) ModuleCatalog {
	catalog := llmProfileCatalog(t)
	catalog.ValidatorChains = NewValidatorChainRegistry()
	return catalog
}

func llmProfilePipeline() PipelineProfile {
	return PipelineProfile{
		ID:     "llm-profile",
		Input:  Binding("llm-input"),
		Chunk:  Binding("llm-chunk"),
		Output: Binding("llm-output"),
		Artifacts: map[string]ArtifactLaneProfile{
			"events": {
				Extract:   Binding("llm-extractor"),
				Merge:     Binding("llm-merge"),
				Normalize: Binding("llm-normalize"),
			},
		},
	}
}

func repairAttempts(value int) *int {
	return &value
}

func llmProfileValues(profile string) map[string]string {
	return map[string]string{
		"input":                      profile,
		"chunk":                      profile,
		"extract":                    profile,
		"merge":                      profile,
		"normalize":                  profile,
		"output":                     profile,
		"validator:chunk:":           profile,
		"validator:extract:events":   profile,
		"validator:merge:events":     profile,
		"validator:normalize:events": profile,
	}
}

func resolvedLLMProfileValues(resolved ResolvedPipeline) map[string]string {
	values := map[string]string{
		"input":  resolved.Input.LLMProfile,
		"chunk":  resolved.Chunk.LLMProfile,
		"output": resolved.Output.LLMProfile,
	}
	lane := resolved.Steps[0].ArtifactLanes[0]
	values["extract"] = lane.Extract.LLMProfile
	values["merge"] = lane.Merge.LLMProfile
	values["normalize"] = lane.Normalize.LLMProfile
	for _, chain := range resolved.ValidatorChains {
		for _, validator := range chain.Validators {
			if validator.ExecutionClass == contracts.ExecutionClassLLMBacked {
				values["validator:"+string(chain.Stage)+":"+chain.LaneID] = validator.Binding.LLMProfile
			}
		}
	}
	return values
}

func registerProfileSpecs(t *testing.T, catalog ModuleCatalog, specs ...ModuleSpec) {
	t.Helper()

	for _, spec := range specs {
		if spec.ArtifactKind == "" && (spec.Stage == StageExtract || spec.Stage == StageMerge || spec.Stage == StageNormalize) {
			spec.ArtifactKind = "test/notes"
		}
		switch spec.Stage {
		case StageInput:
			if err := catalog.Inputs.RegisterWithSpec(spec, profileInputConstructor(spec.Key)); err != nil {
				t.Fatalf("register input spec %#v: %v", spec, err)
			}
		case StageChunk:
			validateOptions := func(options map[string]any) error { return RejectUnknownOptions(options, "a", "b", "size") }
			if err := catalog.Chunkers.RegisterBuilderWithSpec(spec, validateOptions, func(BuildRequest) (contracts.Chunker, error) { return &typedTestChunker{key: spec.Key}, nil }); err != nil {
				t.Fatalf("register chunk spec %#v: %v", spec, err)
			}
		case StageExtract:
			if err := RegisterExtractor(catalog.Extractors, spec, func() (contracts.Extractor[codecNotes], error) {
				return typedTestExtractor[codecNotes]{key: spec.Key}, nil
			}); err != nil {
				t.Fatalf("register extractor spec %#v: %v", spec, err)
			}
		case StageMerge:
			if err := RegisterMerger(catalog.Mergers, spec, func() (contracts.Merger[codecNotes], error) { return typedTestMerger[codecNotes]{key: spec.Key}, nil }); err != nil {
				t.Fatalf("register merger spec %#v: %v", spec, err)
			}
		case StageNormalize:
			if err := RegisterNormalizer(catalog.Normalizers, spec, func() (contracts.Normalizer[codecNotes], error) {
				return typedTestNormalizer[codecNotes]{key: spec.Key}, nil
			}); err != nil {
				t.Fatalf("register normalizer spec %#v: %v", spec, err)
			}
		case StageValidate:
			validatorSpec := ValidatorSpec{Key: spec.Key, ExecutionClass: contracts.ExecutionClassDeterministic}
			if err := RegisterTypedValidator(catalog.Validators, "test/notes", validatorSpec, func() (contracts.TypedValidator[codecNotes], error) {
				return typedTestValidator[codecNotes]{key: spec.Key}, nil
			}); err != nil {
				t.Fatalf("register validator spec %#v: %v", spec, err)
			}
		case StageOutput:
			if err := catalog.Outputs.RegisterWithSpec(spec, profileOutputConstructor(spec.Key)); err != nil {
				t.Fatalf("register output spec %#v: %v", spec, err)
			}
		default:
			t.Fatalf("unsupported spec stage %q", spec.Stage)
		}
	}
}

func registerProfileValidatorSpec(t *testing.T, catalog ModuleCatalog, spec ValidatorSpec) {
	t.Helper()
	if err := RegisterTypedValidator(catalog.Validators, "test/notes", spec, func() (contracts.TypedValidator[codecNotes], error) {
		return typedTestValidator[codecNotes]{key: spec.Key}, nil
	}); err != nil {
		t.Fatalf("register validator spec %#v: %v", spec, err)
	}
}

func profileInputConstructor(key string) InputAdapterConstructor {
	return func() (contracts.InputAdapter, error) {
		return profileInputAdapter{key: key}, nil
	}
}

type profileInputAdapter struct {
	key string
}

func (adapter profileInputAdapter) Key() string {
	return adapter.key
}

func (adapter profileInputAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
	return &source.SourceDocument{}, nil
}

func profileOutputConstructor(key string) OutputEncoderConstructor {
	return func() (contracts.OutputEncoder, error) {
		return registryOutputEncoder{key: key}, nil
	}
}

func TestResolvedPipelineCanMarshalToCanonicalJSON(t *testing.T) {
	resolved, err := ResolvePipeline(baselineProfile(), ResolveOptions{}, newProfileCatalog(t))
	if err != nil {
		t.Fatalf("ResolvePipeline() error = %v, want nil", err)
	}
	encoded, err := json.Marshal(resolved)
	if err != nil {
		t.Fatalf("json.Marshal(resolved) error = %v, want nil", err)
	}
	for _, field := range []string{"input_execution_class", "chunk_execution_class", "extract_execution_class", "merge_execution_class", "normalize_execution_class", "output_execution_class"} {
		if !strings.Contains(string(encoded), `"`+field+`":"deterministic"`) {
			t.Fatalf("resolved JSON does not retain %q: %s", field, encoded)
		}
	}
}
