diff --git a/docs/internal/llm.md b/docs/internal/llm.md index af9f07c..c2c085b 100644 --- a/docs/internal/llm.md +++ b/docs/internal/llm.md @@ -39,9 +39,9 @@ without exposing Scriptorium types through stage contracts. 7. injecting that one shared client into complete pipeline preparation before the source file is read or the runner is invoked. -The D&D scene chunker retains this injected client and uses it for every scene -completion. Later legacy LLM-backed operations still receive the same shared -client through their operation requests. +The D&D scene chunker and spell extractor retain this injected client and use +it for every structured completion. Operation requests do not carry an LLM +client. The CLI separately gathers explicit profile IDs from resolved LLM-capable stage and validator bindings. It prepares a small internal check prompt for each ID so diff --git a/docs/internal/modules.md b/docs/internal/modules.md index c324572..48f3df6 100644 --- a/docs/internal/modules.md +++ b/docs/internal/modules.md @@ -112,8 +112,8 @@ schema, strict option decoder, injected shared LLM client, and prompt/schema manifest metadata. The separate `internal/modules/dnd/codec/spells` package owns the durable schema and stable JSON representation for artifact kind `dnd/spell-list`. The runner keeps the result typed through validators and later -stages. Explicit migration-only codec adapters preserve the existing raw -checkpoint, debug, and output envelopes. Shared D&D helpers keep prompt input +stages, using the codec only for checkpoint, debug, and output boundaries. +Shared D&D helpers keep prompt input names and source-unit reference conversion consistent with the scene chunker. The durable payload and manifest metadata shapes are defined in the @@ -123,16 +123,14 @@ The durable payload and manifest metadata shapes are defined in the ### `internal/modules/generic/merge/appendorder` -The typed merger passes values to an injected combine function in framework +The merger passes typed values to an injected combine function in framework source-chunk order. The D&D registrar specializes it with a spell-list append -function. Its temporary raw implementation retains the prior JSON merge -behavior for the current runner. +function. ### `internal/modules/generic/normalize/noop` -The typed normalizer returns the merged domain value unchanged and is reusable -for any registered artifact type. Its temporary raw implementation defensively -clones the accepted payload for the current runner. +The normalizer returns the merged domain value unchanged and is reusable for +any registered artifact type. ## Output Encoder diff --git a/docs/internal/overview.md b/docs/internal/overview.md index ef830c8..08b9f1b 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -47,8 +47,8 @@ validator set before the runner receives source bytes. | `internal/framework/checkpoint` | Workspace-backed checkpoint loading, recording, and payload serialization. | | `internal/framework/debug` | Workspace-backed framework and LLM debug recording. | -Framework contracts provide both the production raw stage interfaces and typed -artifact, provenance-wrapper, chunk-validator, serialized-validator, and +Framework contracts provide typed artifact, provenance-wrapper, chunk-validator, +serialized-validator, and typed-validator interfaces. The runner owns handoff provenance, validation sequencing, rejection handling, checkpoint and debug boundaries, and final manifest assembly. @@ -60,15 +60,13 @@ type equality across the lane, and records schema identity in the resolved lane and pipeline digest. Registry entries carry separate option-validation and run-local construction closures. Preparation injects shared dependencies and constructs input, chunk, validators, ordered lanes, and output before source -parsing. Production input, chunk, and output modules use strict construction-time -option decoding, and the LLM-backed scene chunker retains the injected shared -client. The D&D family registers the canonical `dnd/spell-list` codec, typed -spell extractor and validators, and kind-specific generic merge and normalize -strategies; generic JSON validators use the serialized-validation contract. The -runner executes the production D&D lane through private exact-type-checked -closures and uses migration-only codec adapters for the existing raw -checkpoint, debug, and output envelopes. Legacy raw lanes retain their separate -executor while they migrate. +parsing. Production modules use strict construction-time option decoding, and +LLM-backed modules retain the injected shared client. The D&D family registers +the canonical `dnd/spell-list` codec, typed spell extractor and validators, and +kind-specific generic merge and normalize strategies; generic JSON validators +use the serialized-validation contract. The runner executes lanes through +private exact-type-checked closures and serializes artifacts only through their +codec at checkpoint, debug, and output boundaries. ## Production Extensions diff --git a/docs/internal/pipeline.md b/docs/internal/pipeline.md index 9b41878..801b77d 100644 --- a/docs/internal/pipeline.md +++ b/docs/internal/pipeline.md @@ -75,20 +75,18 @@ mismatches are rejected deterministically. Production composition registers the D&D spell-list codec and typed extractor, matching typed merge, normalize, and semantic-validator variants, and -serialized JSON validators. The production D&D lane has no parallel raw stage -registration. A standalone raw registration cannot satisfy a typed lane. +serialized JSON validators. Every artifact lane resolves through the typed +registries and a matching codec. A `ModuleSpec` declares its stage plus required and provided capabilities. Chunk, extract, merge, and normalize specs may also declare reference slots. Registry implementations defensively copy spec metadata, reject duplicate keys, and verify that a constructed implementation reports the registered key. -Builder registrations accept `ModuleDependencies` and cloned raw options through -one `BuildRequest`. Production input, chunk, and output builders decode those -options and retain typed values or injected dependencies in the constructed -implementation. A typed extractor registration may explicitly supply a raw -adapter builder for a still-raw downstream lane; the adapter is selected as one -unit and does not expose the typed value to raw consumers. Remaining production -raw-stage registrations are adapted from their zero-argument constructors. +Builder registrations accept `ModuleDependencies` and cloned configuration +options through one `BuildRequest`. Builders decode those options and retain +typed values or injected dependencies in the constructed implementation. +Extractors declare their artifact kind, and merger, normalizer, and validator +resolution selects the matching typed variant. A `ValidatorSpec` declares a validator key and execution class. Resolution uses the execution class to reject incompatible profile bindings before execution. @@ -116,10 +114,9 @@ an LLM client; an LLM-backed chunker receives the shared client during preparation. Their operation requests retain run-specific source, reference, profile, session, and metadata context as applicable. -Prepared typed lanes retain exact-type-checked erased operation closures. The -runner uses those closures to keep each value typed through extraction, -validation, merge, and normalization. Legacy raw lanes continue through their -existing executor while they migrate independently. +Prepared lanes retain exact-type-checked erased operation closures. The runner +uses those closures to keep each value typed through extraction, validation, +merge, and normalization. Source validation requires every unit to carry a canonical self-reference to its containing document and its own unit ID. Explicit clone, checkpoint, and @@ -225,7 +222,7 @@ and recorder implementation are inventoried in The runner owns manifest assembly and handoff summaries but not the durable JSON schema. It records resolved module and lane provenance, validator chains, source/reference identities, selected LLM profiles, normalized and rejected -summaries, status, and timing. Raw payload bytes remain outside the manifest. +summaries, status, and timing. Serialized artifact content remains outside the manifest. Module metadata providers may add non-secret singleton or lane-scoped metadata. Execution errors include stage, module, lane, or validator context. Once a diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 2bb0691..adf7a3a 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -170,12 +170,12 @@ func TestProductionCatalogIncludesProductionModulesValidatorsAndDefaults(t *test { name: "appendorder merger", got: func() (pipeline.ModuleSpec, bool) { return catalog.Mergers.Spec(appendorder.Key) }, - want: appendorder.ModuleSpec(), + want: appendorder.TypedModuleSpec(spells.ModuleSpec().ArtifactKind), }, { name: "noop normalizer", got: func() (pipeline.ModuleSpec, bool) { return catalog.Normalizers.Spec(noop.Key) }, - want: noop.ModuleSpec(), + want: noop.TypedModuleSpec(spells.ModuleSpec().ArtifactKind), }, { name: "json output", @@ -3556,6 +3556,10 @@ func fakeExecutionRegistries(t *testing.T) pipeline.Registries { mergers := pipeline.NewMergerRegistry() normalizers := pipeline.NewNormalizerRegistry() outputs := pipeline.NewOutputEncoderRegistry() + codecs := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(codecs, fakeRunCodec{}); err != nil { + t.Fatal(err) + } if err := inputs.RegisterWithSpec(pipeline.ModuleSpec{Key: "fake/input", Stage: pipeline.StageInput, Provides: []string{"source"}}, func() (contracts.InputAdapter, error) { return fakeRunInputAdapter{}, nil @@ -3567,25 +3571,25 @@ func fakeExecutionRegistries(t *testing.T) pipeline.Registries { }); err != nil { t.Fatalf("register fake chunker: %v", err) } - if err := extractors.RegisterLegacyRawWithSpec(pipeline.ModuleSpec{ + if err := pipeline.RegisterExtractor(extractors, pipeline.ModuleSpec{ Key: "fake/extract", Stage: pipeline.StageExtract, Requires: []string{"chunks"}, Provides: []string{"artifact"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster"}, - }, - }, func() (contracts.LegacyRawExtractor, error) { + }, ArtifactKind: fakeRunArtifactKind, + }, func() (contracts.Extractor[fakeRunArtifact], error) { return fakeRunExtractor{}, nil }); err != nil { t.Fatalf("register fake extractor: %v", err) } - if err := mergers.RegisterLegacyRawWithSpec(pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}}, func() (contracts.LegacyRawMerger, error) { + if err := pipeline.RegisterMerger(mergers, pipeline.ModuleSpec{Key: "appendorder", Stage: pipeline.StageMerge, Requires: []string{"artifact"}, Provides: []string{"merged"}, ArtifactKind: fakeRunArtifactKind}, func() (contracts.Merger[fakeRunArtifact], error) { return fakeRunMerger{}, nil }); err != nil { t.Fatalf("register fake merger: %v", err) } - if err := normalizers.RegisterLegacyRawWithSpec(pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}}, func() (contracts.LegacyRawNormalizer, error) { + if err := pipeline.RegisterNormalizer(normalizers, pipeline.ModuleSpec{Key: "noop", Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}, ArtifactKind: fakeRunArtifactKind}, func() (contracts.Normalizer[fakeRunArtifact], error) { return fakeRunNormalizer{}, nil }); err != nil { t.Fatalf("register fake normalizer: %v", err) @@ -3597,7 +3601,7 @@ func fakeExecutionRegistries(t *testing.T) pipeline.Registries { return pipeline.Registries{ Inputs: inputs, Chunkers: chunkers, - ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), + ArtifactCodecs: codecs, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, @@ -3643,6 +3647,25 @@ func (fakeRunChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (co type fakeRunExtractor struct{} +const fakeRunArtifactKind contracts.ArtifactKind = "test/fake" + +type fakeRunArtifact struct { + Value bool `json:"value"` +} +type fakeRunCodec struct{} + +func (fakeRunCodec) Kind() contracts.ArtifactKind { return fakeRunArtifactKind } +func (fakeRunCodec) Schema() contracts.ArtifactSchema { + return contracts.ArtifactSchema{ID: "fake.artifact", Name: "fake_artifact", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)} +} +func (fakeRunCodec) MediaType() string { return "application/json" } +func (fakeRunCodec) Encode(v fakeRunArtifact) ([]byte, error) { return json.Marshal(v) } +func (fakeRunCodec) Decode(b []byte) (fakeRunArtifact, error) { + var v fakeRunArtifact + err := json.Unmarshal(b, &v) + return v, err +} + func (fakeRunExtractor) Key() string { return "fake/extract" } @@ -3651,16 +3674,8 @@ func (fakeRunExtractor) ReferenceSlots() []contracts.ReferenceSlot { return []contracts.ReferenceSlot{{Name: "roster"}} } -func (fakeRunExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - return contracts.ExtractionResult{ - Output: contracts.ExtractOutput{ - Schema: contracts.ResponseSchema{ID: "fake.artifact", Name: "fake_artifact", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(`{"value":true}`), - MediaType: "application/json", - }, - }, - }, nil +func (fakeRunExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[fakeRunArtifact], error) { + return contracts.TypedExtractionResult[fakeRunArtifact]{Value: fakeRunArtifact{Value: true}}, nil } type fakeRunMerger struct{} @@ -3669,21 +3684,11 @@ func (fakeRunMerger) Key() string { return "appendorder" } -func (fakeRunMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { - output := contracts.MergeOutput{ - LaneID: req.LaneID, - SourceID: req.Source.ID, - Schema: contracts.ResponseSchema{ID: "fake.artifact", Name: "fake_artifact", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(`{"merged":true}`), - MediaType: "application/json", - }, - } +func (fakeRunMerger) Merge(ctx context.Context, req contracts.TypedMergeRequest[fakeRunArtifact]) (contracts.TypedMergeResult[fakeRunArtifact], error) { if len(req.ExtractOutputs) > 0 { - output.Schema = req.ExtractOutputs[0].Schema - output.Payload = req.ExtractOutputs[0].Payload + return contracts.TypedMergeResult[fakeRunArtifact]{Value: req.ExtractOutputs[0].Value}, nil } - return contracts.MergeResult{Output: output}, nil + return contracts.TypedMergeResult[fakeRunArtifact]{}, nil } type fakeRunNormalizer struct{} @@ -3696,15 +3701,8 @@ func (fakeRunNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil } -func (fakeRunNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { - return contracts.NormalizeResult{ - Output: contracts.NormalizeOutput{ - LaneID: req.LaneID, - SourceID: req.MergeOutput.SourceID, - Schema: req.MergeOutput.Schema, - Payload: req.MergeOutput.Payload, - }, - }, nil +func (fakeRunNormalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[fakeRunArtifact]) (contracts.TypedNormalizeResult[fakeRunArtifact], error) { + return contracts.TypedNormalizeResult[fakeRunArtifact]{Value: req.MergeOutput.Value}, nil } func onlyChildDir(t *testing.T, root string) string { @@ -3966,9 +3964,18 @@ func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.Module for _, override := range overrides { specs[override.Key] = override } + for _, key := range []string{"fake/extract", "appendorder", "noop"} { + spec := specs[key] + spec.ArtifactKind = fakeRunArtifactKind + specs[key] = spec + } mustRegisterInput(t, inputs, specs["fake/input"]) mustRegisterChunker(t, chunkers, specs["generic"]) + codecs := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(codecs, fakeRunCodec{}); err != nil { + t.Fatal(err) + } mustRegisterExtractor(t, extractors, specs["fake/extract"]) mustRegisterMerger(t, mergers, specs["appendorder"]) mustRegisterNormalizer(t, normalizers, specs["noop"]) @@ -3977,7 +3984,7 @@ func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.Module return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, - ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), + ArtifactCodecs: codecs, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, @@ -4003,21 +4010,21 @@ func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawExtractor, error) { return fakeRunExtractor{}, nil }); err != nil { + if err := pipeline.RegisterExtractor(registry, spec, func() (contracts.Extractor[fakeRunArtifact], error) { return fakeRunExtractor{}, nil }); err != nil { t.Fatalf("register extractor: %v", err) } } func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawMerger, error) { return fakeRunMerger{}, nil }); err != nil { + if err := pipeline.RegisterMerger(registry, spec, func() (contracts.Merger[fakeRunArtifact], error) { return fakeRunMerger{}, nil }); err != nil { t.Fatalf("register merger: %v", err) } } func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawNormalizer, error) { return fakeRunNormalizer{}, nil }); err != nil { + if err := pipeline.RegisterNormalizer(registry, spec, func() (contracts.Normalizer[fakeRunArtifact], error) { return fakeRunNormalizer{}, nil }); err != nil { t.Fatalf("register normalizer: %v", err) } } @@ -4031,11 +4038,16 @@ func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, func mustRegisterValidator(t *testing.T, registry *pipeline.ValidatorRegistry, spec pipeline.ValidatorSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawValidator, error) { + if err := pipeline.RegisterChunkValidator(registry, spec, func() (contracts.ChunkValidator, error) { return fakeConfigValidator{name: spec.Key, executionClass: spec.ExecutionClass}, nil }); err != nil { t.Fatalf("register validator: %v", err) } + if err := pipeline.RegisterTypedValidator[fakeRunArtifact](registry, fakeRunArtifactKind, spec, func() (contracts.TypedValidator[fakeRunArtifact], error) { + return fakeConfigTypedValidator{fakeConfigValidator{name: spec.Key, executionClass: spec.ExecutionClass}}, nil + }); err != nil { + t.Fatalf("register typed validator: %v", err) + } } type fakeConfigValidator struct { @@ -4051,7 +4063,13 @@ func (validator fakeConfigValidator) ExecutionClass() contracts.ExecutionClass { return validator.executionClass } -func (validator fakeConfigValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { +func (validator fakeConfigValidator) Validate(ctx context.Context, req contracts.ChunkValidationRequest) (contracts.ValidationResult, error) { + return contracts.ValidationResult{Approved: true}, nil +} + +type fakeConfigTypedValidator struct{ fakeConfigValidator } + +func (validator fakeConfigTypedValidator) Validate(ctx context.Context, req contracts.TypedValidationRequest[fakeRunArtifact]) (contracts.ValidationResult, error) { return contracts.ValidationResult{Approved: true}, nil } diff --git a/internal/core/config/file_config_test.go b/internal/core/config/file_config_test.go index b5de6b2..fda4777 100644 --- a/internal/core/config/file_config_test.go +++ b/internal/core/config/file_config_test.go @@ -250,7 +250,7 @@ pipelines: } lane := profile.Artifacts["events"] if !reflect.DeepEqual(lane.References, map[string]string{"lore": "./lore.md", "roster": "./legacy-roster.yml"}) { - t.Fatalf("legacy lane references = %#v, want trimmed map", lane.References) + t.Fatalf("lane references = %#v, want trimmed map", lane.References) } wantExtract := map[string]string{ "glossary": "./glossary.md", diff --git a/internal/core/config/validation_test.go b/internal/core/config/validation_test.go index d025df8..a600d77 100644 --- a/internal/core/config/validation_test.go +++ b/internal/core/config/validation_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "strings" "testing" @@ -497,6 +498,11 @@ func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.Module for _, override := range overrides { specs[override.Key] = override } + for _, key := range []string{"fake/extract", "appendorder", "noop"} { + spec := specs[key] + spec.ArtifactKind = fakeArtifactKind + specs[key] = spec + } inputs := pipeline.NewInputAdapterRegistry() chunkers := pipeline.NewChunkerRegistry() @@ -515,10 +521,15 @@ func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.Module mustRegisterValidator(t, validators, specs["fake/llm-validator"]) mustRegisterOutput(t, outputs, specs["json"]) + codecs := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(codecs, fakeArtifactCodec{}); err != nil { + t.Fatalf("register artifact codec: %v", err) + } + return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, - ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), + ArtifactCodecs: codecs, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, @@ -544,21 +555,32 @@ func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawExtractor, error) { return nil, nil }); err != nil { + validateOptions := func(options map[string]any) error { + if err := pipeline.RejectUnknownOptions(options, "temperature"); err != nil { + return err + } + if value, ok := options["temperature"]; ok { + if _, ok := value.(float64); !ok { + return fmt.Errorf("temperature must be a number") + } + } + return nil + } + if err := pipeline.RegisterExtractorBuilder[fakeArtifact](registry, spec, validateOptions, func(pipeline.BuildRequest) (contracts.Extractor[fakeArtifact], error) { return nil, nil }); err != nil { t.Fatalf("register extractor: %v", err) } } func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawMerger, error) { return nil, nil }); err != nil { + if err := pipeline.RegisterMerger[fakeArtifact](registry, spec, func() (contracts.Merger[fakeArtifact], error) { return nil, nil }); err != nil { t.Fatalf("register merger: %v", err) } } func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawNormalizer, error) { return nil, nil }); err != nil { + if err := pipeline.RegisterNormalizer[fakeArtifact](registry, spec, func() (contracts.Normalizer[fakeArtifact], error) { return nil, nil }); err != nil { t.Fatalf("register normalizer: %v", err) } } @@ -570,11 +592,32 @@ func mustRegisterValidator(t *testing.T, registry *pipeline.ValidatorRegistry, s executionClass = contracts.ExecutionClassLLMBacked } validatorSpec := pipeline.ValidatorSpec{Key: spec.Key, ExecutionClass: executionClass} - if err := registry.RegisterLegacyRawWithSpec(validatorSpec, func() (contracts.LegacyRawValidator, error) { return nil, nil }); err != nil { + if err := pipeline.RegisterTypedValidator[fakeArtifact](registry, fakeArtifactKind, validatorSpec, func() (contracts.TypedValidator[fakeArtifact], error) { return nil, nil }); err != nil { t.Fatalf("register validator: %v", err) } } +const fakeArtifactKind contracts.ArtifactKind = "test/artifact" + +type fakeArtifact string + +type fakeArtifactCodec struct{} + +func (fakeArtifactCodec) Kind() contracts.ArtifactKind { return fakeArtifactKind } +func (fakeArtifactCodec) Schema() contracts.ArtifactSchema { + return contracts.ArtifactSchema{ID: "urn:notarius:test:artifact", Name: "Test artifact", Version: "1", JSONSchema: []byte(`{"type":"string"}`)} +} +func (fakeArtifactCodec) MediaType() string { return "application/json" } +func (fakeArtifactCodec) Encode(value fakeArtifact) ([]byte, error) { + return []byte(fmt.Sprintf("%q", value)), nil +} +func (fakeArtifactCodec) Decode(content []byte) (fakeArtifact, error) { + if len(content) < 2 { + return "", fmt.Errorf("invalid test artifact") + } + return fakeArtifact(content[1 : len(content)-1]), nil +} + func mustRegisterOutput(t *testing.T, registry *pipeline.OutputEncoderRegistry, spec pipeline.ModuleSpec) { t.Helper() if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return nil, nil }); err != nil { diff --git a/internal/framework/checkpoint/loader.go b/internal/framework/checkpoint/loader.go index e919629..e997e64 100644 --- a/internal/framework/checkpoint/loader.go +++ b/internal/framework/checkpoint/loader.go @@ -88,147 +88,77 @@ func (l *WorkspaceLoader) Chunk(moduleKey string, sourceDigest string) (pipeline return pipeline.ChunkCheckpoint{Chunks: chunks, Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() } -func (l *WorkspaceLoader) Extract(laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.ExtractCheckpoint, pipeline.CheckpointDecision) { - var manifest coreworkspace.ExtractLaneManifest - if decision := l.readJSON(laneManifestPath("extract", laneID), &manifest); !decision.Reused { - return pipeline.ExtractCheckpoint{}, decision - } - if decision := l.validateLaneManifest(manifest.StageManifest, coreworkspace.StageExtract, laneID, moduleKey, dependencies, coreworkspace.StatusSucceeded, coreworkspace.StatusSucceededWithRejections); !decision.Reused { - return pipeline.ExtractCheckpoint{}, decision - } - var payload extractOutputsEnvelope - if decision := l.readJSON(lanePayloadPath("extract", laneID, "outputs.json"), &payload); !decision.Reused { - return pipeline.ExtractCheckpoint{}, decision - } - outputs, err := extractOutputsFromEnvelope(payload.Outputs) - if err != nil { - return pipeline.ExtractCheckpoint{}, invalidDecision("extract checkpoint payload is invalid: %v", err) - } - if !fingerprintsEqual(coreworkspaceToPipelineFingerprints(manifest.OutputDigests), rawOutputDigests(extractPayloads(outputs))) { - return pipeline.ExtractCheckpoint{}, invalidDecision("extract checkpoint output digests do not match payload") - } - return pipeline.ExtractCheckpoint{ - Outputs: outputs, - Rejected: cloneRejectedOutputs(payload.Rejected), - Warnings: cloneWarnings(payload.Warnings), - }, reusedDecision() -} - -func (l *WorkspaceLoader) ArtifactExtract(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.ArtifactExtractCheckpoint, pipeline.CheckpointDecision) { +func (l *WorkspaceLoader) Extract(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.ExtractCheckpoint, pipeline.CheckpointDecision) { var manifest coreworkspace.ExtractLaneManifest if d := l.readJSON(laneManifestPath("extract", laneID), &manifest); !d.Reused { - return pipeline.ArtifactExtractCheckpoint{}, d + return pipeline.ExtractCheckpoint{}, d } if d := l.validateLaneManifest(manifest.StageManifest, coreworkspace.StageExtract, laneID, moduleKey, dependencies, coreworkspace.StatusSucceeded, coreworkspace.StatusSucceededWithRejections); !d.Reused { - return pipeline.ArtifactExtractCheckpoint{}, d + return pipeline.ExtractCheckpoint{}, d } var payload artifactExtractEnvelope if d := l.readJSON(lanePayloadPath("extract", laneID, "outputs.json"), &payload); !d.Reused { - return pipeline.ArtifactExtractCheckpoint{}, d + return pipeline.ExtractCheckpoint{}, d } outputs, err := artifactCheckpointOutputs(payload.Outputs) if err != nil { - return pipeline.ArtifactExtractCheckpoint{}, invalidDecision("extract artifact checkpoint payload is invalid: %v", err) + return pipeline.ExtractCheckpoint{}, invalidDecision("extract artifact checkpoint payload is invalid: %v", err) } if !fingerprintsEqual(coreworkspaceToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(outputs)) { - return pipeline.ArtifactExtractCheckpoint{}, invalidDecision("extract artifact checkpoint output digests do not match payload") + return pipeline.ExtractCheckpoint{}, invalidDecision("extract artifact checkpoint output digests do not match payload") } - return pipeline.ArtifactExtractCheckpoint{Outputs: outputs, Rejected: cloneRejectedOutputs(payload.Rejected), Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() + return pipeline.ExtractCheckpoint{Outputs: outputs, Rejected: cloneRejectedOutputs(payload.Rejected), Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() } -func (l *WorkspaceLoader) Merge(laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.MergeCheckpoint, pipeline.CheckpointDecision) { - var manifest coreworkspace.MergeLaneManifest - if decision := l.readJSON(laneManifestPath("merge", laneID), &manifest); !decision.Reused { - return pipeline.MergeCheckpoint{}, decision - } - if decision := l.validateLaneManifest(manifest.StageManifest, coreworkspace.StageMerge, laneID, moduleKey, dependencies, coreworkspace.StatusSucceeded); !decision.Reused { - return pipeline.MergeCheckpoint{}, decision - } - var payload mergeOutputEnvelope - if decision := l.readJSON(lanePayloadPath("merge", laneID, "output.json"), &payload); !decision.Reused { - return pipeline.MergeCheckpoint{}, decision - } - output, err := mergeOutputFromEnvelope(payload.Output) - if err != nil { - return pipeline.MergeCheckpoint{}, invalidDecision("merge checkpoint payload is invalid: %v", err) - } - if !fingerprintsEqual(coreworkspaceToPipelineFingerprints(manifest.OutputDigests), rawOutputDigests([]contracts.RawPayload{output.Payload})) { - return pipeline.MergeCheckpoint{}, invalidDecision("merge checkpoint output digest does not match payload") - } - return pipeline.MergeCheckpoint{Output: output, Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() -} - -func (l *WorkspaceLoader) ArtifactMerge(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.ArtifactMergeCheckpoint, pipeline.CheckpointDecision) { +func (l *WorkspaceLoader) Merge(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.MergeCheckpoint, pipeline.CheckpointDecision) { var manifest coreworkspace.MergeLaneManifest if d := l.readJSON(laneManifestPath("merge", laneID), &manifest); !d.Reused { - return pipeline.ArtifactMergeCheckpoint{}, d + return pipeline.MergeCheckpoint{}, d } if d := l.validateLaneManifest(manifest.StageManifest, coreworkspace.StageMerge, laneID, moduleKey, dependencies, coreworkspace.StatusSucceeded); !d.Reused { - return pipeline.ArtifactMergeCheckpoint{}, d + return pipeline.MergeCheckpoint{}, d } var payload artifactSingleEnvelope if d := l.readJSON(lanePayloadPath("merge", laneID, "output.json"), &payload); !d.Reused { - return pipeline.ArtifactMergeCheckpoint{}, d + return pipeline.MergeCheckpoint{}, d } values, err := artifactCheckpointOutputs([]artifactCheckpointEnvelope{payload.Output}) if err != nil { - return pipeline.ArtifactMergeCheckpoint{}, invalidDecision("merge artifact checkpoint payload is invalid: %v", err) + return pipeline.MergeCheckpoint{}, invalidDecision("merge artifact checkpoint payload is invalid: %v", err) } if !fingerprintsEqual(coreworkspaceToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { - return pipeline.ArtifactMergeCheckpoint{}, invalidDecision("merge artifact checkpoint output digest does not match payload") + return pipeline.MergeCheckpoint{}, invalidDecision("merge artifact checkpoint output digest does not match payload") } - return pipeline.ArtifactMergeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() + return pipeline.MergeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() } -func (l *WorkspaceLoader) Normalize(laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.NormalizeCheckpoint, pipeline.CheckpointDecision) { - var manifest coreworkspace.NormalizeLaneManifest - if decision := l.readJSON(laneManifestPath("normalize", laneID), &manifest); !decision.Reused { - return pipeline.NormalizeCheckpoint{}, decision - } - if decision := l.validateLaneManifest(manifest.StageManifest, coreworkspace.StageNormalize, laneID, moduleKey, dependencies, coreworkspace.StatusSucceeded); !decision.Reused { - return pipeline.NormalizeCheckpoint{}, decision - } - var payload normalizeOutputEnvelope - if decision := l.readJSON(lanePayloadPath("normalize", laneID, "output.json"), &payload); !decision.Reused { - return pipeline.NormalizeCheckpoint{}, decision - } - output, err := normalizeOutputFromEnvelope(payload.Output) - if err != nil { - return pipeline.NormalizeCheckpoint{}, invalidDecision("normalize checkpoint payload is invalid: %v", err) - } - if !fingerprintsEqual(coreworkspaceToPipelineFingerprints(manifest.OutputDigests), rawOutputDigests([]contracts.RawPayload{output.Payload})) { - return pipeline.NormalizeCheckpoint{}, invalidDecision("normalize checkpoint output digest does not match payload") - } - return pipeline.NormalizeCheckpoint{Output: output, Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() -} - -func (l *WorkspaceLoader) ArtifactNormalize(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.ArtifactNormalizeCheckpoint, pipeline.CheckpointDecision) { +func (l *WorkspaceLoader) Normalize(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint) (pipeline.NormalizeCheckpoint, pipeline.CheckpointDecision) { var manifest coreworkspace.NormalizeLaneManifest if d := l.readJSON(laneManifestPath("normalize", laneID), &manifest); !d.Reused { - return pipeline.ArtifactNormalizeCheckpoint{}, d + return pipeline.NormalizeCheckpoint{}, d } if d := l.validateLaneManifest(manifest.StageManifest, coreworkspace.StageNormalize, laneID, moduleKey, dependencies, coreworkspace.StatusSucceeded); !d.Reused { - return pipeline.ArtifactNormalizeCheckpoint{}, d + return pipeline.NormalizeCheckpoint{}, d } var payload artifactSingleEnvelope if d := l.readJSON(lanePayloadPath("normalize", laneID, "output.json"), &payload); !d.Reused { - return pipeline.ArtifactNormalizeCheckpoint{}, d + return pipeline.NormalizeCheckpoint{}, d } values, err := artifactCheckpointOutputs([]artifactCheckpointEnvelope{payload.Output}) if err != nil { - return pipeline.ArtifactNormalizeCheckpoint{}, invalidDecision("normalize artifact checkpoint payload is invalid: %v", err) + return pipeline.NormalizeCheckpoint{}, invalidDecision("normalize artifact checkpoint payload is invalid: %v", err) } if !fingerprintsEqual(coreworkspaceToPipelineFingerprints(manifest.OutputDigests), artifactOutputDigests(values)) { - return pipeline.ArtifactNormalizeCheckpoint{}, invalidDecision("normalize artifact checkpoint output digest does not match payload") + return pipeline.NormalizeCheckpoint{}, invalidDecision("normalize artifact checkpoint output digest does not match payload") } - return pipeline.ArtifactNormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() + return pipeline.NormalizeCheckpoint{Output: values[0], Warnings: cloneWarnings(payload.Warnings)}, reusedDecision() } -func artifactCheckpointOutputs(values []artifactCheckpointEnvelope) ([]pipeline.ArtifactCheckpointOutput, error) { +func artifactCheckpointOutputs(values []artifactCheckpointEnvelope) ([]pipeline.CheckpointArtifact, error) { if len(values) == 0 { return nil, nil } - out := make([]pipeline.ArtifactCheckpointOutput, 0, len(values)) + out := make([]pipeline.CheckpointArtifact, 0, len(values)) for _, v := range values { content, err := contentFromEnvelope(v.Content) if err != nil { @@ -237,7 +167,7 @@ func artifactCheckpointOutputs(values []artifactCheckpointEnvelope) ([]pipeline. if strings.TrimSpace(string(v.Kind)) == "" || strings.TrimSpace(v.Schema.ID) == "" || strings.TrimSpace(v.Schema.Version) == "" || strings.TrimSpace(v.SchemaDigest) == "" { return nil, fmt.Errorf("artifact codec identity is incomplete") } - out = append(out, pipeline.ArtifactCheckpointOutput{LaneID: v.LaneID, ModuleKey: v.ModuleKey, SourceID: v.SourceID, ChunkID: v.ChunkID, ChunkIndex: v.ChunkIndex, ChunkRef: v.ChunkRef, SchemaDigest: v.SchemaDigest, Artifact: contracts.SerializedArtifact{Kind: v.Kind, Schema: v.Schema, MediaType: v.Content.MediaType, Content: content, Metadata: cloneMetadata(v.Content.Metadata)}}) + out = append(out, pipeline.CheckpointArtifact{LaneID: v.LaneID, ModuleKey: v.ModuleKey, SourceID: v.SourceID, ChunkID: v.ChunkID, ChunkIndex: v.ChunkIndex, ChunkRef: v.ChunkRef, SchemaDigest: v.SchemaDigest, Artifact: contracts.SerializedArtifact{Kind: v.Kind, Schema: v.Schema, MediaType: v.Content.MediaType, Content: content, Metadata: cloneMetadata(v.Content.Metadata)}}) } return out, nil } @@ -326,70 +256,6 @@ func sourceChunksFromEnvelope(values []chunkEnvelope) ([]source.Chunk, error) { return out, nil } -func extractOutputsFromEnvelope(values []extractOutputEnvelope) ([]contracts.ExtractOutput, error) { - if len(values) == 0 { - return nil, nil - } - out := make([]contracts.ExtractOutput, 0, len(values)) - for _, value := range values { - payload, err := rawPayloadFromEnvelope(value.Payload) - if err != nil { - return nil, err - } - out = append(out, contracts.ExtractOutput{ - LaneID: value.LaneID, - ExtractorKey: value.ExtractorKey, - SourceID: value.SourceID, - ChunkID: value.ChunkID, - ChunkIndex: value.ChunkIndex, - Schema: value.Schema, - Payload: payload, - }) - } - return out, nil -} - -func mergeOutputFromEnvelope(value mergeOutputPayload) (contracts.MergeOutput, error) { - payload, err := rawPayloadFromEnvelope(value.Payload) - if err != nil { - return contracts.MergeOutput{}, err - } - return contracts.MergeOutput{ - LaneID: value.LaneID, - MergerKey: value.MergerKey, - SourceID: value.SourceID, - Schema: value.Schema, - Payload: payload, - }, nil -} - -func normalizeOutputFromEnvelope(value normalizeOutputPayload) (contracts.NormalizeOutput, error) { - payload, err := rawPayloadFromEnvelope(value.Payload) - if err != nil { - return contracts.NormalizeOutput{}, err - } - return contracts.NormalizeOutput{ - LaneID: value.LaneID, - NormalizerKey: value.NormalizerKey, - SourceID: value.SourceID, - Schema: value.Schema, - Payload: payload, - }, nil -} - -func rawPayloadFromEnvelope(value binaryEnvelope) (contracts.RawPayload, error) { - content, err := contentFromEnvelope(value) - if err != nil { - return contracts.RawPayload{}, err - } - return contracts.RawPayload{ - Content: content, - MediaType: value.MediaType, - Metadata: cloneMetadata(value.Metadata), - Warnings: cloneWarnings(value.Warnings), - }, nil -} - func contentFromEnvelope(value binaryEnvelope) ([]byte, error) { content, err := base64.StdEncoding.DecodeString(value.ContentBase64) if err != nil { diff --git a/internal/framework/checkpoint/recorder.go b/internal/framework/checkpoint/recorder.go index 9ff378f..ebaa60a 100644 --- a/internal/framework/checkpoint/recorder.go +++ b/internal/framework/checkpoint/recorder.go @@ -119,28 +119,7 @@ func (r *WorkspaceRecorder) ExtractRunning(laneID string, moduleKey string, depe return r.writeManifest(laneManifestPath("extract", laneID), coreworkspace.ExtractLaneManifest{StageManifest: manifest}) } -func (r *WorkspaceRecorder) ExtractSucceeded(laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint, outputs []contracts.ExtractOutput, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { - payload := extractOutputsEnvelope{ - Outputs: extractOutputEnvelopes(outputs), - Rejected: cloneRejectedOutputs(rejected), - Warnings: cloneWarnings(warnings), - } - if err := r.writePayload(lanePayloadPath("extract", laneID, "outputs.json"), payload); err != nil { - return err - } - manifest := r.laneManifest(coreworkspace.StageExtract, statusForRejected(rejected), laneID, moduleKey, dependencies) - manifest.OutputDigests = workspaceFingerprints(rawOutputDigests(extractPayloads(outputs))) - manifest.ValidationStatus = validationStatusString(warnings, rejected) - manifest.Rejections = rejectionSummaries(rejected) - manifest.CompletedAt = timePtr(r.timestamp()) - return r.writeManifest(laneManifestPath("extract", laneID), coreworkspace.ExtractLaneManifest{ - StageManifest: manifest, - ChunkCount: len(outputs) + len(rejected), - OutputCount: len(outputs), - }) -} - -func (r *WorkspaceRecorder) ArtifactExtractSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, outputs []pipeline.ArtifactCheckpointOutput, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { +func (r *WorkspaceRecorder) ExtractSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, outputs []pipeline.CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { payload := artifactExtractEnvelope{Outputs: artifactCheckpointEnvelopes(outputs), Rejected: cloneRejectedOutputs(rejected), Warnings: cloneWarnings(warnings)} if err := r.writePayload(lanePayloadPath("extract", laneID, "outputs.json"), payload); err != nil { return err @@ -166,27 +145,12 @@ func (r *WorkspaceRecorder) MergeRunning(laneID string, moduleKey string, depend return r.writeManifest(laneManifestPath("merge", laneID), coreworkspace.MergeLaneManifest{StageManifest: manifest}) } -func (r *WorkspaceRecorder) MergeSucceeded(laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output contracts.MergeOutput, warnings []contracts.Warning) error { - payload := mergeOutputEnvelope{Output: mergeOutputEnvelopeFromOutput(output), Warnings: cloneWarnings(warnings)} - if err := r.writePayload(lanePayloadPath("merge", laneID, "output.json"), payload); err != nil { - return err - } - manifest := r.laneManifest(coreworkspace.StageMerge, coreworkspace.StatusSucceeded, laneID, moduleKey, dependencies) - manifest.OutputDigests = workspaceFingerprints(rawOutputDigests([]contracts.RawPayload{output.Payload})) - manifest.ValidationStatus = validationStatusString(warnings, nil) - manifest.CompletedAt = timePtr(r.timestamp()) - return r.writeManifest(laneManifestPath("merge", laneID), coreworkspace.MergeLaneManifest{ - StageManifest: manifest, - InputCount: len(dependencies), - }) -} - -func (r *WorkspaceRecorder) ArtifactMergeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.ArtifactCheckpointOutput, warnings []contracts.Warning) error { +func (r *WorkspaceRecorder) MergeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact, warnings []contracts.Warning) error { if err := r.writePayload(lanePayloadPath("merge", laneID, "output.json"), artifactSingleEnvelope{Output: artifactCheckpointEnvelopeFromOutput(output), Warnings: cloneWarnings(warnings)}); err != nil { return err } manifest := r.laneManifest(coreworkspace.StageMerge, coreworkspace.StatusSucceeded, laneID, moduleKey, dependencies) - manifest.OutputDigests = workspaceFingerprints(artifactOutputDigests([]pipeline.ArtifactCheckpointOutput{output})) + manifest.OutputDigests = workspaceFingerprints(artifactOutputDigests([]pipeline.CheckpointArtifact{output})) manifest.ValidationStatus = validationStatusString(warnings, nil) manifest.CompletedAt = timePtr(r.timestamp()) return r.writeManifest(laneManifestPath("merge", laneID), coreworkspace.MergeLaneManifest{StageManifest: manifest, InputCount: len(dependencies)}) @@ -213,24 +177,12 @@ func (r *WorkspaceRecorder) NormalizeRunning(laneID string, moduleKey string, de return r.writeManifest(laneManifestPath("normalize", laneID), coreworkspace.NormalizeLaneManifest{StageManifest: manifest}) } -func (r *WorkspaceRecorder) NormalizeSucceeded(laneID string, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output contracts.NormalizeOutput, warnings []contracts.Warning) error { - payload := normalizeOutputEnvelope{Output: normalizeOutputEnvelopeFromOutput(output), Warnings: cloneWarnings(warnings)} - if err := r.writePayload(lanePayloadPath("normalize", laneID, "output.json"), payload); err != nil { - return err - } - manifest := r.laneManifest(coreworkspace.StageNormalize, coreworkspace.StatusSucceeded, laneID, moduleKey, dependencies) - manifest.OutputDigests = workspaceFingerprints(rawOutputDigests([]contracts.RawPayload{output.Payload})) - manifest.ValidationStatus = validationStatusString(warnings, nil) - manifest.CompletedAt = timePtr(r.timestamp()) - return r.writeManifest(laneManifestPath("normalize", laneID), coreworkspace.NormalizeLaneManifest{StageManifest: manifest, InputCount: len(dependencies)}) -} - -func (r *WorkspaceRecorder) ArtifactNormalizeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.ArtifactCheckpointOutput, warnings []contracts.Warning) error { +func (r *WorkspaceRecorder) NormalizeSucceeded(laneID, moduleKey string, dependencies []pipeline.CheckpointFingerprint, output pipeline.CheckpointArtifact, warnings []contracts.Warning) error { if err := r.writePayload(lanePayloadPath("normalize", laneID, "output.json"), artifactSingleEnvelope{Output: artifactCheckpointEnvelopeFromOutput(output), Warnings: cloneWarnings(warnings)}); err != nil { return err } manifest := r.laneManifest(coreworkspace.StageNormalize, coreworkspace.StatusSucceeded, laneID, moduleKey, dependencies) - manifest.OutputDigests = workspaceFingerprints(artifactOutputDigests([]pipeline.ArtifactCheckpointOutput{output})) + manifest.OutputDigests = workspaceFingerprints(artifactOutputDigests([]pipeline.CheckpointArtifact{output})) manifest.ValidationStatus = validationStatusString(warnings, nil) manifest.CompletedAt = timePtr(r.timestamp()) return r.writeManifest(laneManifestPath("normalize", laneID), coreworkspace.NormalizeLaneManifest{StageManifest: manifest, InputCount: len(dependencies)}) @@ -308,48 +260,6 @@ type chunkEnvelope struct { Metadata map[string]any `json:"metadata,omitempty"` } -type extractOutputsEnvelope struct { - Outputs []extractOutputEnvelope `json:"outputs"` - Rejected []contracts.RejectedOutput `json:"rejected,omitempty"` - Warnings []contracts.Warning `json:"warnings,omitempty"` -} - -type extractOutputEnvelope struct { - LaneID string `json:"lane_id"` - ExtractorKey string `json:"extractor_key"` - SourceID string `json:"source_id"` - ChunkID string `json:"chunk_id"` - ChunkIndex int `json:"chunk_index"` - Schema contracts.ResponseSchema `json:"schema,omitempty"` - Payload binaryEnvelope `json:"payload"` -} - -type mergeOutputEnvelope struct { - Output mergeOutputPayload `json:"output"` - Warnings []contracts.Warning `json:"warnings,omitempty"` -} - -type mergeOutputPayload struct { - LaneID string `json:"lane_id"` - MergerKey string `json:"merger_key"` - SourceID string `json:"source_id,omitempty"` - Schema contracts.ResponseSchema `json:"schema,omitempty"` - Payload binaryEnvelope `json:"payload"` -} - -type normalizeOutputEnvelope struct { - Output normalizeOutputPayload `json:"output"` - Warnings []contracts.Warning `json:"warnings,omitempty"` -} - -type normalizeOutputPayload struct { - LaneID string `json:"lane_id"` - NormalizerKey string `json:"normalizer_key"` - SourceID string `json:"source_id,omitempty"` - Schema contracts.ResponseSchema `json:"schema,omitempty"` - Payload binaryEnvelope `json:"payload"` -} - type binaryEnvelope struct { ContentBase64 string `json:"content_base64,omitempty"` ContentDigest string `json:"content_digest,omitempty"` @@ -380,12 +290,12 @@ type artifactSingleEnvelope struct { Warnings []contracts.Warning `json:"warnings,omitempty"` } -func artifactCheckpointEnvelopeFromOutput(output pipeline.ArtifactCheckpointOutput) artifactCheckpointEnvelope { +func artifactCheckpointEnvelopeFromOutput(output pipeline.CheckpointArtifact) artifactCheckpointEnvelope { schema := contracts.CloneArtifactSchema(output.Artifact.Schema) schema.JSONSchema = nil return artifactCheckpointEnvelope{LaneID: output.LaneID, ModuleKey: output.ModuleKey, SourceID: output.SourceID, ChunkID: output.ChunkID, ChunkIndex: output.ChunkIndex, ChunkRef: output.ChunkRef, Kind: output.Artifact.Kind, Schema: schema, SchemaDigest: output.SchemaDigest, Content: binaryEnvelopeFromContent(output.Artifact.Content, output.Artifact.MediaType, output.Artifact.Metadata, nil)} } -func artifactCheckpointEnvelopes(outputs []pipeline.ArtifactCheckpointOutput) []artifactCheckpointEnvelope { +func artifactCheckpointEnvelopes(outputs []pipeline.CheckpointArtifact) []artifactCheckpointEnvelope { if len(outputs) == 0 { return nil } @@ -395,7 +305,7 @@ func artifactCheckpointEnvelopes(outputs []pipeline.ArtifactCheckpointOutput) [] } return out } -func artifactOutputDigests(outputs []pipeline.ArtifactCheckpointOutput) []pipeline.CheckpointFingerprint { +func artifactOutputDigests(outputs []pipeline.CheckpointArtifact) []pipeline.CheckpointFingerprint { values := make([]pipeline.CheckpointFingerprint, 0, len(outputs)) for i, v := range outputs { values = append(values, pipeline.CheckpointFingerprint{Name: fmt.Sprintf("artifact[%d]", i), Value: contentDigest(v.Artifact.Content)}) @@ -422,54 +332,6 @@ func chunkEnvelopes(chunks []source.Chunk) []chunkEnvelope { return out } -func extractOutputEnvelopes(outputs []contracts.ExtractOutput) []extractOutputEnvelope { - if len(outputs) == 0 { - return nil - } - out := make([]extractOutputEnvelope, 0, len(outputs)) - for _, output := range outputs { - out = append(out, extractOutputEnvelope{ - LaneID: output.LaneID, - ExtractorKey: output.ExtractorKey, - SourceID: output.SourceID, - ChunkID: output.ChunkID, - ChunkIndex: output.ChunkIndex, - Schema: schemaEnvelope(output.Schema), - Payload: binaryEnvelopeFromPayload(output.Payload), - }) - } - return out -} - -func mergeOutputEnvelopeFromOutput(output contracts.MergeOutput) mergeOutputPayload { - return mergeOutputPayload{ - LaneID: output.LaneID, - MergerKey: output.MergerKey, - SourceID: output.SourceID, - Schema: schemaEnvelope(output.Schema), - Payload: binaryEnvelopeFromPayload(output.Payload), - } -} - -func normalizeOutputEnvelopeFromOutput(output contracts.NormalizeOutput) normalizeOutputPayload { - return normalizeOutputPayload{ - LaneID: output.LaneID, - NormalizerKey: output.NormalizerKey, - SourceID: output.SourceID, - Schema: schemaEnvelope(output.Schema), - Payload: binaryEnvelopeFromPayload(output.Payload), - } -} - -func schemaEnvelope(schema contracts.ResponseSchema) contracts.ResponseSchema { - schema.JSONSchema = nil - return schema -} - -func binaryEnvelopeFromPayload(payload contracts.RawPayload) binaryEnvelope { - return binaryEnvelopeFromContent(payload.Content, payload.MediaType, payload.Metadata, payload.Warnings) -} - func binaryEnvelopeFromContent(content []byte, mediaType string, metadata map[string]any, warnings []contracts.Warning) binaryEnvelope { return binaryEnvelope{ ContentBase64: base64.StdEncoding.EncodeToString(content), @@ -528,28 +390,6 @@ func cloneMetadata(metadata map[string]any) map[string]any { return out } -func rawOutputDigests(payloads []contracts.RawPayload) []pipeline.CheckpointFingerprint { - values := make([]pipeline.CheckpointFingerprint, 0, len(payloads)) - for i, payload := range payloads { - values = append(values, pipeline.CheckpointFingerprint{ - Name: fmt.Sprintf("payload[%d]", i), - Value: contentDigest(payload.Content), - }) - } - return normalizeFingerprints(values) -} - -func extractPayloads(outputs []contracts.ExtractOutput) []contracts.RawPayload { - if len(outputs) == 0 { - return nil - } - payloads := make([]contracts.RawPayload, 0, len(outputs)) - for _, output := range outputs { - payloads = append(payloads, output.Payload) - } - return payloads -} - func chunkOutputDigests(chunks []source.Chunk) ([]pipeline.CheckpointFingerprint, error) { values := make([]pipeline.CheckpointFingerprint, 0, len(chunks)) for _, chunk := range chunks { diff --git a/internal/framework/checkpoint/recorder_test.go b/internal/framework/checkpoint/recorder_test.go index e94aef8..cfe1794 100644 --- a/internal/framework/checkpoint/recorder_test.go +++ b/internal/framework/checkpoint/recorder_test.go @@ -79,117 +79,19 @@ func TestWorkspaceRecorderWritesSuccessfulCheckpointFiles(t *testing.T) { } } -func TestWorkspaceLoaderReusesSuccessfulCheckpointFiles(t *testing.T) { - root := t.TempDir() - recorder := newTestRecorder(t, root) - loader := &WorkspaceLoader{root: root} - doc := &source.SourceDocument{ - ID: "source-1", - Kind: "document", - Format: "text/plain", - Digest: "sha256:source", - Units: []source.SourceUnit{{ID: 1, Kind: "line", Text: "hello", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}}}, - } - chunks := []source.Chunk{ - { - ID: "chunk-1", - SourceID: "source-1", - Index: 0, - Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, - Content: []byte("chunk content"), - MediaType: "text/plain", - Units: doc.Units, - }, - } - extractOutput := contracts.ExtractOutput{ - LaneID: "spells", - ExtractorKey: "dnd/spells", - SourceID: doc.ID, - ChunkID: "chunk-1", - Payload: contracts.RawPayload{ - Content: []byte(`{"spell":"cure wounds"}`), - MediaType: "application/json", - }, - } - mergeOutput := contracts.MergeOutput{ - LaneID: "spells", - MergerKey: "appendorder", - SourceID: doc.ID, - Payload: contracts.RawPayload{ - Content: []byte(`{"merged":true}`), - MediaType: "application/json", - }, - } - normalizeOutput := contracts.NormalizeOutput{ - LaneID: "spells", - NormalizerKey: "noop", - SourceID: doc.ID, - Payload: contracts.RawPayload{ - Content: []byte(`{"normalized":true}`), - MediaType: "application/json", - }, - } - - if err := recorder.SourceSucceeded("seriatim", doc); err != nil { - t.Fatalf("SourceSucceeded: %v", err) - } - if err := recorder.ChunkSucceeded("generic", doc.Digest, chunks, nil); err != nil { - t.Fatalf("ChunkSucceeded: %v", err) - } - extractDeps := []pipeline.CheckpointFingerprint{{Name: "chunks", Value: "sha256:chunks"}} - if err := recorder.ExtractSucceeded("spells", "dnd/spells", extractDeps, []contracts.ExtractOutput{extractOutput}, nil, nil); err != nil { - t.Fatalf("ExtractSucceeded: %v", err) - } - mergeDeps := rawOutputDigests([]contracts.RawPayload{extractOutput.Payload}) - if err := recorder.MergeSucceeded("spells", "appendorder", mergeDeps, mergeOutput, nil); err != nil { - t.Fatalf("MergeSucceeded: %v", err) - } - normalizeDeps := rawOutputDigests([]contracts.RawPayload{mergeOutput.Payload}) - if err := recorder.NormalizeSucceeded("spells", "noop", normalizeDeps, normalizeOutput, nil); err != nil { - t.Fatalf("NormalizeSucceeded: %v", err) - } - - sourceCheckpoint, decision := loader.Source("seriatim") - if !decision.Reused || sourceCheckpoint.Document.ID != "source-1" { - t.Fatalf("source decision = %#v checkpoint=%#v, want reused", decision, sourceCheckpoint) - } - if got, want := sourceCheckpoint.Document.Units[0].Ref, doc.Units[0].Ref; got != want { - t.Fatalf("checkpoint source unit ref = %#v, want %#v", got, want) - } - chunkCheckpoint, decision := loader.Chunk("generic", doc.Digest) - if !decision.Reused || len(chunkCheckpoint.Chunks) != 1 || string(chunkCheckpoint.Chunks[0].Content) != "chunk content" { - t.Fatalf("chunk decision = %#v checkpoint=%#v, want reused", decision, chunkCheckpoint) - } - if got, want := chunkCheckpoint.Chunks[0].Ref, chunks[0].Ref; got != want { - t.Fatalf("checkpoint chunk ref = %#v, want %#v", got, want) - } - extractCheckpoint, decision := loader.Extract("spells", "dnd/spells", extractDeps) - if !decision.Reused || len(extractCheckpoint.Outputs) != 1 || string(extractCheckpoint.Outputs[0].Payload.Content) != `{"spell":"cure wounds"}` { - t.Fatalf("extract decision = %#v checkpoint=%#v, want reused", decision, extractCheckpoint) - } - mergeCheckpoint, decision := loader.Merge("spells", "appendorder", mergeDeps) - if !decision.Reused || string(mergeCheckpoint.Output.Payload.Content) != `{"merged":true}` { - t.Fatalf("merge decision = %#v checkpoint=%#v, want reused", decision, mergeCheckpoint) - } - normalizeCheckpoint, decision := loader.Normalize("spells", "noop", normalizeDeps) - if !decision.Reused || string(normalizeCheckpoint.Output.Payload.Content) != `{"normalized":true}` { - t.Fatalf("normalize decision = %#v checkpoint=%#v, want reused", decision, normalizeCheckpoint) - } -} - func TestWorkspaceArtifactCheckpointsRoundTripCodecIdentityAndBytes(t *testing.T) { root := t.TempDir() recorder := newTestRecorder(t, root) loader := &WorkspaceLoader{root: root} schema := contracts.ArtifactSchema{ID: "dnd.spell_response", Name: "spell response", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)} artifact := contracts.SerializedArtifact{Kind: "dnd.spells", Schema: schema, MediaType: "application/json", Content: []byte(`{"spell_casts":[]}`), Metadata: map[string]any{"spell_cast_count": float64(0)}} - stored := pipeline.ArtifactCheckpointOutput{LaneID: "spells", ModuleKey: "dnd/spells", SourceID: "source-1", ChunkID: "chunk-1", ChunkIndex: 2, ChunkRef: source.SourceRef{SourceID: "source-1", StartUnitID: 4, EndUnitID: 8}, Artifact: artifact, SchemaDigest: contracts.DigestArtifactSchema(schema)} + stored := pipeline.CheckpointArtifact{LaneID: "spells", ModuleKey: "dnd/spells", SourceID: "source-1", ChunkID: "chunk-1", ChunkIndex: 2, ChunkRef: source.SourceRef{SourceID: "source-1", StartUnitID: 4, EndUnitID: 8}, Artifact: artifact, SchemaDigest: contracts.DigestArtifactSchema(schema)} extractDeps := []pipeline.CheckpointFingerprint{{Name: "chunks", Value: "sha256:chunks"}} - if err := recorder.ArtifactExtractSucceeded("spells", "dnd/spells", extractDeps, []pipeline.ArtifactCheckpointOutput{stored}, nil, nil); err != nil { - t.Fatalf("ArtifactExtractSucceeded: %v", err) + if err := recorder.ExtractSucceeded("spells", "dnd/spells", extractDeps, []pipeline.CheckpointArtifact{stored}, nil, nil); err != nil { + t.Fatalf("ExtractSucceeded: %v", err) } - extracted, decision := loader.ArtifactExtract("spells", "dnd/spells", extractDeps) + extracted, decision := loader.Extract("spells", "dnd/spells", extractDeps) if !decision.Reused || len(extracted.Outputs) != 1 { t.Fatalf("extract decision=%#v checkpoint=%#v, want reused", decision, extracted) } @@ -198,20 +100,20 @@ func TestWorkspaceArtifactCheckpointsRoundTripCodecIdentityAndBytes(t *testing.T t.Fatalf("artifact checkpoint = %#v, want codec identity, bytes, and provenance", got) } - mergeDeps := artifactOutputDigests([]pipeline.ArtifactCheckpointOutput{stored}) - if err := recorder.ArtifactMergeSucceeded("spells", "merge", mergeDeps, stored, nil); err != nil { - t.Fatalf("ArtifactMergeSucceeded: %v", err) + mergeDeps := artifactOutputDigests([]pipeline.CheckpointArtifact{stored}) + if err := recorder.MergeSucceeded("spells", "merge", mergeDeps, stored, nil); err != nil { + t.Fatalf("MergeSucceeded: %v", err) } - merged, decision := loader.ArtifactMerge("spells", "merge", mergeDeps) + merged, decision := loader.Merge("spells", "merge", mergeDeps) if !decision.Reused || string(merged.Output.Artifact.Content) != string(artifact.Content) { t.Fatalf("merge decision=%#v checkpoint=%#v, want reused", decision, merged) } - normalizeDeps := artifactOutputDigests([]pipeline.ArtifactCheckpointOutput{stored}) - if err := recorder.ArtifactNormalizeSucceeded("spells", "normalize", normalizeDeps, stored, nil); err != nil { - t.Fatalf("ArtifactNormalizeSucceeded: %v", err) + normalizeDeps := artifactOutputDigests([]pipeline.CheckpointArtifact{stored}) + if err := recorder.NormalizeSucceeded("spells", "normalize", normalizeDeps, stored, nil); err != nil { + t.Fatalf("NormalizeSucceeded: %v", err) } - normalized, decision := loader.ArtifactNormalize("spells", "normalize", normalizeDeps) + normalized, decision := loader.Normalize("spells", "normalize", normalizeDeps) if !decision.Reused || normalized.Output.SchemaDigest != stored.SchemaDigest { t.Fatalf("normalize decision=%#v checkpoint=%#v, want reused", decision, normalized) } @@ -361,23 +263,20 @@ func TestWorkspaceRecorderRecordsFailedStages(t *testing.T) { func TestWorkspaceRecorderRecordsWarningOnlyValidation(t *testing.T) { root := t.TempDir() recorder := newTestRecorder(t, root) - output := contracts.NormalizeOutput{ - LaneID: "spells", - NormalizerKey: "noop", - SourceID: "source-1", - Payload: contracts.RawPayload{ - Content: []byte(`{"ok":true}`), - MediaType: "application/json", - }, + schema := contracts.ArtifactSchema{ID: "test.artifact", Name: "test_artifact", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)} + output := pipeline.CheckpointArtifact{ + LaneID: "events", ModuleKey: "noop", SourceID: "source-1", + Artifact: contracts.SerializedArtifact{Kind: "test/artifact", Schema: schema, MediaType: "application/json", Content: []byte(`{"ok":true}`)}, + SchemaDigest: contracts.DigestArtifactSchema(schema), } warnings := []contracts.Warning{{ReasonCode: "note", Message: "warning"}} - if err := recorder.NormalizeSucceeded("spells", "noop", nil, output, warnings); err != nil { + if err := recorder.NormalizeSucceeded("events", "noop", nil, output, warnings); err != nil { t.Fatalf("NormalizeSucceeded: %v", err) } var manifest coreworkspace.NormalizeLaneManifest - readJSON(t, filepath.Join(root, "normalize", "spells", "manifest.json"), &manifest) + readJSON(t, filepath.Join(root, "normalize", "events", "manifest.json"), &manifest) if manifest.Status != coreworkspace.StatusSucceeded || manifest.ValidationStatus != "approved_with_warnings" { t.Fatalf("normalize manifest status = %q validation=%q", manifest.Status, manifest.ValidationStatus) } diff --git a/internal/framework/contracts/composition_test.go b/internal/framework/contracts/composition_test.go deleted file mode 100644 index a305ed0..0000000 --- a/internal/framework/contracts/composition_test.go +++ /dev/null @@ -1,289 +0,0 @@ -package contracts_test - -import ( - "context" - "encoding/json" - "errors" - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" - "gitea.maximumdirect.net/eric/notarius/internal/core/source" - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -var _ contracts.InputAdapter = compositionAdapter{} -var _ contracts.Chunker = compositionChunker{} -var _ contracts.LegacyRawExtractor = compositionExtractor{} -var _ contracts.LegacyRawMerger = compositionMerger{} -var _ contracts.LegacyRawNormalizer = compositionNormalizer{} -var _ contracts.LegacyRawValidator = compositionValidator{} -var _ contracts.OutputEncoder = compositionOutputEncoder{} - -func TestContractsComposeAcrossPackages(t *testing.T) { - ctx := context.Background() - adapter := compositionAdapter{} - chunker := compositionChunker{} - extractor := compositionExtractor{} - merger := compositionMerger{} - normalizer := compositionNormalizer{} - encoder := compositionOutputEncoder{} - - doc, err := adapter.Parse(ctx, contracts.ParseRequest{SourceID: "source-1"}) - if err != nil { - t.Fatalf("Parse() error = %v, want nil", err) - } - if err := source.ValidateDocument(doc); err != nil { - t.Fatalf("ValidateDocument() error = %v, want nil", err) - } - - chunking, err := chunker.Chunk(ctx, contracts.ChunkRequest{ - Source: doc, - Metadata: map[string]any{"request": "test"}, - }) - if err != nil { - t.Fatalf("Chunk() error = %v, want nil", err) - } - if len(chunking.Chunks) != 1 { - t.Fatalf("len(Chunks) = %d, want 1", len(chunking.Chunks)) - } - - extraction, err := extractor.Extract(ctx, contracts.ExtractionRequest{ - Source: doc, - Chunk: &chunking.Chunks[0], - AmbientContext: map[string]any{"synopsis": "example synopsis"}, - }) - if err != nil { - t.Fatalf("Extract() error = %v, want nil", err) - } - if extraction.Output.Payload.MediaType != "application/json" { - t.Fatalf("extract media type = %q, want application/json", extraction.Output.Payload.MediaType) - } - - merge, err := merger.Merge(ctx, contracts.MergeRequest{ - Source: doc, - LaneID: "generic-lane", - ExtractOutputs: []contracts.ExtractOutput{extraction.Output}, - }) - if err != nil { - t.Fatalf("Merge() error = %v, want nil", err) - } - if string(merge.Output.Payload.Content) != `{"value":"example"}` { - t.Fatalf("merge output = %s, want extract payload", merge.Output.Payload.Content) - } - - normalize, err := normalizer.Normalize(ctx, contracts.NormalizeRequest{ - Source: doc, - LaneID: "generic-lane", - MergeOutput: merge.Output, - }) - if err != nil { - t.Fatalf("Normalize() error = %v, want nil", err) - } - if string(normalize.Output.Payload.Content) != `{"value":"example"}` { - t.Fatalf("normalize output = %s, want merge payload", normalize.Output.Payload.Content) - } - - output, err := encoder.Encode(ctx, contracts.OutputRequest{ - Manifest: artifacts.RunManifest{RunID: "run-1"}, - NormalizeOutputs: []contracts.SerializedOutput{{LaneID: normalize.Output.LaneID, NormalizerKey: normalize.Output.NormalizerKey, SourceID: normalize.Output.SourceID, Artifact: contracts.SerializedArtifact{Schema: contracts.ArtifactSchema{ID: normalize.Output.Schema.ID, Name: normalize.Output.Schema.Name, Version: normalize.Output.Schema.Version}, MediaType: normalize.Output.Payload.MediaType, Content: append([]byte(nil), normalize.Output.Payload.Content...)}}}, - }) - if err != nil { - t.Fatalf("Encode() error = %v, want nil", err) - } - if len(output.Files) != 1 { - t.Fatalf("len(Files) = %d, want 1", len(output.Files)) - } - if output.Files[0].ContentType != "application/json" { - t.Fatalf("ContentType = %q, want application/json", output.Files[0].ContentType) - } - if len(output.Files[0].Bytes) == 0 { - t.Fatal("len(Bytes) = 0, want encoded bytes") - } -} - -type compositionAdapter struct{} - -func (adapter compositionAdapter) Key() string { - return "generic-input" -} - -func (adapter compositionAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { - return &source.SourceDocument{ - ID: req.SourceID, - Kind: "document", - Format: "text/plain", - Digest: "sha256:abc123", - Units: []source.SourceUnit{ - {ID: 1, Kind: "unit", Text: "First source unit.", Ref: source.SourceRef{SourceID: req.SourceID, StartUnitID: 1, EndUnitID: 1}}, - {ID: 2, Kind: "unit", Text: "Second source unit.", Ref: source.SourceRef{SourceID: req.SourceID, StartUnitID: 2, EndUnitID: 2}}, - }, - }, nil -} - -type compositionChunker struct{} - -func (chunker compositionChunker) Key() string { - return "generic-chunker" -} - -func (chunker compositionChunker) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (chunker compositionChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) { - if req.Source == nil { - return contracts.ChunkResult{}, errors.New("source document is required") - } - return contracts.ChunkResult{ - Chunks: []source.Chunk{ - { - ID: req.Source.ID + ":chunk:0", - SourceID: req.Source.ID, - Index: 0, - Ref: source.SourceRef{ - SourceID: req.Source.ID, - StartUnitID: req.Source.Units[0].ID, - EndUnitID: req.Source.Units[len(req.Source.Units)-1].ID, - }, - Content: []byte(`{"units":[{"id":1,"kind":"unit","text":"First source unit."},{"id":2,"kind":"unit","text":"Second source unit."}]}`), - MediaType: "application/json", - Units: append([]source.SourceUnit(nil), req.Source.Units...), - Metadata: map[string]any{"strategy": "whole-document"}, - }, - }, - }, nil -} - -type compositionExtractor struct{} - -func (extractor compositionExtractor) Key() string { - return "generic-extractor" -} - -func (extractor compositionExtractor) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (extractor compositionExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - if req.Source == nil { - return contracts.ExtractionResult{}, errors.New("source document is required") - } - if req.AmbientContext["synopsis"] == "" { - return contracts.ExtractionResult{}, errors.New("ambient synopsis is required") - } - - return contracts.ExtractionResult{ - Output: contracts.ExtractOutput{ - Schema: contracts.ResponseSchema{ID: "schema-id", Name: "schema-name", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(`{"value":"example"}`), - MediaType: "application/json", - }, - }, - }, nil -} - -type compositionMerger struct{} - -func (merger compositionMerger) Key() string { - return "generic-merger" -} - -func (merger compositionMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { - output := req.ExtractOutputs[0] - return contracts.MergeResult{Output: contracts.MergeOutput{ - LaneID: req.LaneID, - MergerKey: merger.Key(), - SourceID: output.SourceID, - Schema: output.Schema, - Payload: cloneCompositionPayload(output.Payload), - }}, nil -} - -type compositionNormalizer struct{} - -func (normalizer compositionNormalizer) Key() string { - return "generic-normalizer" -} - -func (normalizer compositionNormalizer) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (normalizer compositionNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { - return contracts.NormalizeResult{Output: contracts.NormalizeOutput{ - LaneID: req.LaneID, - NormalizerKey: normalizer.Key(), - SourceID: req.MergeOutput.SourceID, - Schema: req.MergeOutput.Schema, - Payload: cloneCompositionPayload(req.MergeOutput.Payload), - }}, nil -} - -func cloneCompositionPayload(payload contracts.RawPayload) contracts.RawPayload { - return contracts.RawPayload{ - Content: append([]byte(nil), payload.Content...), - MediaType: payload.MediaType, - Metadata: cloneCompositionMetadata(payload.Metadata), - Warnings: append([]contracts.Warning(nil), payload.Warnings...), - } -} - -func cloneCompositionMetadata(metadata map[string]any) map[string]any { - if len(metadata) == 0 { - return nil - } - out := make(map[string]any, len(metadata)) - for key, value := range metadata { - out[key] = value - } - return out -} - -type compositionValidator struct{} - -func (validator compositionValidator) Name() string { - return "generic-validator" -} - -func (validator compositionValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} - -func (validator compositionValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - return contracts.ValidationResult{ - Approved: true, - ReasonCode: "accepted", - Message: "output accepted", - }, nil -} - -type compositionOutputEncoder struct{} - -func (encoder compositionOutputEncoder) Key() string { - return "generic-output" -} - -func (encoder compositionOutputEncoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { - payload := struct { - RunID string `json:"run_id"` - OutputCount int `json:"output_count"` - }{ - RunID: req.Manifest.RunID, - OutputCount: len(req.NormalizeOutputs), - } - encoded, err := json.Marshal(payload) - if err != nil { - return contracts.OutputResult{}, err - } - - return contracts.OutputResult{ - Files: []contracts.OutputFile{ - { - Name: "artifacts/generic.json", - ContentType: "application/json", - Bytes: encoded, - }, - }, - }, nil -} diff --git a/internal/framework/contracts/contracts.go b/internal/framework/contracts/contracts.go index b238a88..52cc236 100644 --- a/internal/framework/contracts/contracts.go +++ b/internal/framework/contracts/contracts.go @@ -207,37 +207,6 @@ type ReferenceSet struct { Slots map[string]ResolvedReferenceSlot `json:"slots,omitempty"` } -type ExtractionRequest struct { - Source *source.SourceDocument `json:"-"` - Chunk *source.Chunk `json:"chunk,omitempty"` - AmbientContext map[string]any `json:"ambient_context,omitempty"` - SourceInput LLMInputMaterial `json:"source_input,omitempty"` - SessionID string `json:"session_id,omitempty"` - References ReferenceSet `json:"references,omitempty"` - LLMClient StructuredLLMClient `json:"-"` - LLMProfile string `json:"llm_profile,omitempty"` - Options map[string]any `json:"options,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type ExtractionResult struct { - Output ExtractOutput `json:"output"` - Warnings []Warning `json:"warnings,omitempty"` -} - -type LegacyRawExtractor interface { - Key() string - ReferenceSlots() []ReferenceSlot - Extract(ctx context.Context, req ExtractionRequest) (ExtractionResult, error) -} - -type RawPayload struct { - Content []byte `json:"-"` - MediaType string `json:"media_type"` - Metadata map[string]any `json:"metadata,omitempty"` - Warnings []Warning `json:"warnings,omitempty"` -} - type ExecutionClass string const ( @@ -245,29 +214,6 @@ const ( ExecutionClassLLMBacked ExecutionClass = "llm_backed" ) -type ValidationRequest struct { - Stage string `json:"stage"` - LaneID string `json:"lane_id,omitempty"` - ModuleKey string `json:"module_key"` - Source *source.SourceDocument `json:"-"` - SourceID string `json:"source_id,omitempty"` - SourceInput LLMInputMaterial `json:"source_input,omitempty"` - SessionID string `json:"session_id,omitempty"` - References ReferenceSet `json:"references,omitempty"` - LLMClient StructuredLLMClient `json:"-"` - LLMProfile string `json:"llm_profile,omitempty"` - Options map[string]any `json:"options,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - Schema ResponseSchema `json:"schema,omitempty"` - Payload RawPayload `json:"payload"` - ChunkID string `json:"chunk_id,omitempty"` - ChunkIndex int `json:"chunk_index,omitempty"` - Chunk *source.Chunk `json:"chunk,omitempty"` - Chunks []source.Chunk `json:"chunks,omitempty"` - ExtractOutputs []ExtractOutput `json:"extract_outputs,omitempty"` - MergeOutput MergeOutput `json:"merge_output,omitempty"` -} - type ValidationResult struct { Approved bool `json:"approved"` ReasonCode string `json:"reason_code,omitempty"` @@ -276,92 +222,6 @@ type ValidationResult struct { Warnings []Warning `json:"warnings,omitempty"` } -type LegacyRawValidator interface { - Name() string - ExecutionClass() ExecutionClass - Validate(ctx context.Context, req ValidationRequest) (ValidationResult, error) -} - -type ResponseSchema struct { - ID string `json:"id,omitempty"` - Name string `json:"name,omitempty"` - Version string `json:"version,omitempty"` - JSONSchema []byte `json:"-"` -} - -type ExtractOutput struct { - LaneID string `json:"lane_id"` - ExtractorKey string `json:"extractor_key"` - SourceID string `json:"source_id"` - ChunkID string `json:"chunk_id"` - ChunkIndex int `json:"chunk_index"` - Schema ResponseSchema `json:"schema,omitempty"` - Payload RawPayload `json:"payload"` -} - -type MergeRequest struct { - Source *source.SourceDocument `json:"-"` - LaneID string `json:"lane_id"` - ExtractOutputs []ExtractOutput `json:"extract_outputs"` - SourceInput LLMInputMaterial `json:"source_input,omitempty"` - SessionID string `json:"session_id,omitempty"` - References ReferenceSet `json:"references,omitempty"` - LLMClient StructuredLLMClient `json:"-"` - LLMProfile string `json:"llm_profile,omitempty"` - Options map[string]any `json:"options,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type MergeResult struct { - Output MergeOutput `json:"output"` - Warnings []Warning `json:"warnings,omitempty"` -} - -type MergeOutput struct { - LaneID string `json:"lane_id"` - MergerKey string `json:"merger_key"` - SourceID string `json:"source_id,omitempty"` - Schema ResponseSchema `json:"schema,omitempty"` - Payload RawPayload `json:"payload"` -} - -type LegacyRawMerger interface { - Key() string - Merge(ctx context.Context, req MergeRequest) (MergeResult, error) -} - -type NormalizeRequest struct { - Source *source.SourceDocument `json:"-"` - LaneID string `json:"lane_id"` - MergeOutput MergeOutput `json:"merge_output"` - SourceInput LLMInputMaterial `json:"source_input,omitempty"` - SessionID string `json:"session_id,omitempty"` - References ReferenceSet `json:"references,omitempty"` - LLMClient StructuredLLMClient `json:"-"` - LLMProfile string `json:"llm_profile,omitempty"` - Options map[string]any `json:"options,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type NormalizeResult struct { - Output NormalizeOutput `json:"output"` - Warnings []Warning `json:"warnings,omitempty"` -} - -type NormalizeOutput struct { - LaneID string `json:"lane_id"` - NormalizerKey string `json:"normalizer_key"` - SourceID string `json:"source_id,omitempty"` - Schema ResponseSchema `json:"schema,omitempty"` - Payload RawPayload `json:"payload"` -} - -type LegacyRawNormalizer interface { - Key() string - ReferenceSlots() []ReferenceSlot - Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) -} - type Warning struct { Scope string `json:"scope,omitempty"` ReasonCode string `json:"reason_code"` diff --git a/internal/framework/contracts/contracts_test.go b/internal/framework/contracts/contracts_test.go index 861ca49..5d7a579 100644 --- a/internal/framework/contracts/contracts_test.go +++ b/internal/framework/contracts/contracts_test.go @@ -12,14 +12,13 @@ import ( var _ InputAdapter = fakeAdapter{} var _ Chunker = fakeChunker{} -var _ LegacyRawExtractor = fakeExtractor{} -var _ LegacyRawMerger = fakeMerger{} -var _ LegacyRawNormalizer = fakeNormalizer{} -var _ LegacyRawValidator = fakeValidator{} +var _ Extractor[fakeArtifact] = fakeExtractor{} +var _ Merger[fakeArtifact] = fakeMerger{} +var _ Normalizer[fakeArtifact] = fakeNormalizer{} var _ StructuredLLMClient = fakeLLMClient{} var _ OutputEncoder = fakeOutputEncoder{} -func TestFakeExtractorReturnsRawOutput(t *testing.T) { +func TestFakeExtractorReturnsTypedOutput(t *testing.T) { extractor := fakeExtractor{ key: "generic-extractor", } @@ -33,7 +32,7 @@ func TestFakeExtractorReturnsRawOutput(t *testing.T) { }, } - result, err := extractor.Extract(context.Background(), ExtractionRequest{Source: doc}) + result, err := extractor.Extract(context.Background(), TypedExtractionRequest{Source: doc}) if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } @@ -41,14 +40,8 @@ func TestFakeExtractorReturnsRawOutput(t *testing.T) { if extractor.Key() != "generic-extractor" { t.Fatalf("Key() = %q, want generic-extractor", extractor.Key()) } - if result.Output.ExtractorKey != "" { - t.Fatalf("ExtractorKey = %q, want runner-owned empty value", result.Output.ExtractorKey) - } - if result.Output.Schema.Version != "v1" { - t.Fatalf("Schema.Version = %q, want v1", result.Output.Schema.Version) - } - if result.Output.Payload.MediaType != "application/json" || string(result.Output.Payload.Content) != `{"value":"example"}` { - t.Fatalf("payload = %q %s, want JSON raw output", result.Output.Payload.MediaType, result.Output.Payload.Content) + if result.Value.Value != "example" { + t.Fatalf("Value = %q, want example", result.Value.Value) } } @@ -139,7 +132,7 @@ func TestFakeExtractorReceivesChunkAndAmbientContext(t *testing.T) { Units: []source.SourceUnit{doc.Units[1]}, } - result, err := extractor.Extract(context.Background(), ExtractionRequest{ + result, err := extractor.Extract(context.Background(), TypedExtractionRequest{ Source: doc, Chunk: &chunk, AmbientContext: map[string]any{"mode": "chunked"}, @@ -147,11 +140,8 @@ func TestFakeExtractorReceivesChunkAndAmbientContext(t *testing.T) { if err != nil { t.Fatalf("Extract() error = %v, want nil", err) } - if result.Output.ChunkID != "" || result.Output.ChunkIndex != 0 { - t.Fatalf("chunk provenance = %q/%d, want runner-owned zero values", result.Output.ChunkID, result.Output.ChunkIndex) - } - if string(result.Output.Payload.Content) != `{"value":"chunked"}` { - t.Fatalf("Payload.Content = %s, want chunked payload", result.Output.Payload.Content) + if result.Value.Value != "chunked" { + t.Fatalf("Value = %q, want chunked", result.Value.Value) } } @@ -320,8 +310,8 @@ func TestLLMInputSetCloneCopiesContent(t *testing.T) { } } -func TestResponseSchemaJSONOmitRawSchemaContent(t *testing.T) { - schema := ResponseSchema{ +func TestArtifactSchemaJSONOmitsSchemaContent(t *testing.T) { + schema := ArtifactSchema{ ID: "schema-id", Name: "schema-name", Version: "v1", @@ -347,26 +337,14 @@ func TestResponseSchemaJSONOmitRawSchemaContent(t *testing.T) { } func TestFakeMergeNormalizeAndOutputContracts(t *testing.T) { - extractOutput := ExtractOutput{ - LaneID: "generic-lane", - ExtractorKey: "generic-extractor", - SourceID: "source-1", - ChunkID: "source-1:chunk:0", - ChunkIndex: 0, - Schema: ResponseSchema{ID: "schema-id", Name: "schema-name", Version: "v1"}, - Payload: RawPayload{ - Content: []byte(`{"value":"example"}`), - MediaType: "application/json", - Metadata: map[string]any{"confidence": 0.75}, - }, - } + extractOutput := ExtractArtifact[fakeArtifact]{LaneID: "generic-lane", ExtractorKey: "generic-extractor", SourceID: "source-1", ChunkID: "source-1:chunk:0", ChunkIndex: 0, Value: fakeArtifact{Value: "example"}} merger := fakeMerger{key: "generic-merger"} normalizer := fakeNormalizer{key: "generic-normalizer"} encoder := fakeOutputEncoder{key: "generic-output"} - merged, err := merger.Merge(context.Background(), MergeRequest{ + merged, err := merger.Merge(context.Background(), TypedMergeRequest[fakeArtifact]{ LaneID: "generic-lane", - ExtractOutputs: []ExtractOutput{extractOutput}, + ExtractOutputs: []ExtractArtifact[fakeArtifact]{extractOutput}, }) if err != nil { t.Fatalf("Merge() error = %v, want nil", err) @@ -374,13 +352,13 @@ func TestFakeMergeNormalizeAndOutputContracts(t *testing.T) { if merger.Key() != "generic-merger" { t.Fatalf("Merger.Key() = %q, want generic-merger", merger.Key()) } - if string(merged.Output.Payload.Content) != `{"value":"example"}` { - t.Fatalf("merged content = %s, want raw extract content", merged.Output.Payload.Content) + if merged.Value.Value != "example" { + t.Fatalf("merged value = %q, want example", merged.Value.Value) } - normalized, err := normalizer.Normalize(context.Background(), NormalizeRequest{ + normalized, err := normalizer.Normalize(context.Background(), TypedNormalizeRequest[fakeArtifact]{ LaneID: "generic-lane", - MergeOutput: merged.Output, + MergeOutput: MergeArtifact[fakeArtifact]{LaneID: "generic-lane", MergerKey: merger.Key(), SourceID: "source-1", Value: merged.Value}, }) if err != nil { t.Fatalf("Normalize() error = %v, want nil", err) @@ -388,13 +366,13 @@ func TestFakeMergeNormalizeAndOutputContracts(t *testing.T) { if normalizer.Key() != "generic-normalizer" { t.Fatalf("Normalizer.Key() = %q, want generic-normalizer", normalizer.Key()) } - if string(normalized.Output.Payload.Content) != `{"value":"example"}` { - t.Fatalf("normalized content = %s, want raw merge content", normalized.Output.Payload.Content) + if normalized.Value.Value != "example" { + t.Fatalf("normalized value = %q, want example", normalized.Value.Value) } encoded, err := encoder.Encode(context.Background(), OutputRequest{ Manifest: artifacts.RunManifest{RunID: "run-1"}, - NormalizeOutputs: []SerializedOutput{serializedTestOutput(normalized.Output)}, + NormalizeOutputs: []SerializedOutput{{LaneID: "generic-lane", NormalizerKey: normalizer.Key(), SourceID: "source-1", Artifact: SerializedArtifact{Kind: "test/artifact", Schema: ArtifactSchema{ID: "schema-id", Name: "schema-name", Version: "v1"}, MediaType: "application/json", Content: []byte(`{"value":"example"}`)}}}, }) if err != nil { t.Fatalf("Encode() error = %v, want nil", err) @@ -413,10 +391,6 @@ func TestFakeMergeNormalizeAndOutputContracts(t *testing.T) { } } -func serializedTestOutput(output NormalizeOutput) SerializedOutput { - return SerializedOutput{LaneID: output.LaneID, NormalizerKey: output.NormalizerKey, SourceID: output.SourceID, Artifact: SerializedArtifact{Schema: ArtifactSchema{ID: output.Schema.ID, Name: output.Schema.Name, Version: output.Schema.Version, JSONSchema: append([]byte(nil), output.Schema.JSONSchema...)}, MediaType: output.Payload.MediaType, Content: append([]byte(nil), output.Payload.Content...), Metadata: cloneArtifactMetadata(output.Payload.Metadata)}} -} - func TestOutputFileJSONShapeOmitsBytes(t *testing.T) { file := OutputFile{ Name: "artifacts/events.json", @@ -514,6 +488,8 @@ type fakeExtractor struct { key string } +type fakeArtifact struct{ Value string } + func (extractor fakeExtractor) Key() string { return extractor.key } @@ -522,21 +498,12 @@ func (extractor fakeExtractor) ReferenceSlots() []ReferenceSlot { return nil } -func (extractor fakeExtractor) Extract(ctx context.Context, req ExtractionRequest) (ExtractionResult, error) { - payload := json.RawMessage(`{"value":"example"}`) +func (extractor fakeExtractor) Extract(ctx context.Context, req TypedExtractionRequest) (TypedExtractionResult[fakeArtifact], error) { + value := "example" if req.AmbientContext["mode"] == "chunked" { - payload = json.RawMessage(`{"value":"chunked"}`) + value = "chunked" } - - return ExtractionResult{ - Output: ExtractOutput{ - Schema: ResponseSchema{ID: "schema-id", Name: "schema-name", Version: "v1"}, - Payload: RawPayload{ - Content: append([]byte(nil), payload...), - MediaType: "application/json", - }, - }, - }, nil + return TypedExtractionResult[fakeArtifact]{Value: fakeArtifact{Value: value}}, nil } type fakeMerger struct { @@ -547,15 +514,8 @@ func (merger fakeMerger) Key() string { return merger.key } -func (merger fakeMerger) Merge(ctx context.Context, req MergeRequest) (MergeResult, error) { - output := req.ExtractOutputs[0] - return MergeResult{Output: MergeOutput{ - LaneID: req.LaneID, - MergerKey: merger.key, - SourceID: output.SourceID, - Schema: output.Schema, - Payload: cloneTestRawPayload(output.Payload), - }}, nil +func (merger fakeMerger) Merge(ctx context.Context, req TypedMergeRequest[fakeArtifact]) (TypedMergeResult[fakeArtifact], error) { + return TypedMergeResult[fakeArtifact]{Value: req.ExtractOutputs[0].Value}, nil } type fakeNormalizer struct { @@ -570,54 +530,8 @@ func (normalizer fakeNormalizer) ReferenceSlots() []ReferenceSlot { return nil } -func (normalizer fakeNormalizer) Normalize(ctx context.Context, req NormalizeRequest) (NormalizeResult, error) { - return NormalizeResult{Output: NormalizeOutput{ - LaneID: req.LaneID, - NormalizerKey: normalizer.key, - SourceID: req.MergeOutput.SourceID, - Schema: req.MergeOutput.Schema, - Payload: cloneTestRawPayload(req.MergeOutput.Payload), - }}, nil -} - -func cloneTestRawPayload(payload RawPayload) RawPayload { - return RawPayload{ - Content: append([]byte(nil), payload.Content...), - MediaType: payload.MediaType, - Metadata: cloneTestMetadata(payload.Metadata), - Warnings: append([]Warning(nil), payload.Warnings...), - } -} - -func cloneTestMetadata(metadata map[string]any) map[string]any { - if len(metadata) == 0 { - return nil - } - out := make(map[string]any, len(metadata)) - for key, value := range metadata { - out[key] = value - } - return out -} - -type fakeValidator struct { - name string -} - -func (validator fakeValidator) Name() string { - return validator.name -} - -func (validator fakeValidator) ExecutionClass() ExecutionClass { - return ExecutionClassDeterministic -} - -func (validator fakeValidator) Validate(ctx context.Context, req ValidationRequest) (ValidationResult, error) { - return ValidationResult{ - Approved: true, - ReasonCode: "accepted", - Message: "output accepted", - }, nil +func (normalizer fakeNormalizer) Normalize(ctx context.Context, req TypedNormalizeRequest[fakeArtifact]) (TypedNormalizeResult[fakeArtifact], error) { + return TypedNormalizeResult[fakeArtifact]{Value: req.MergeOutput.Value}, nil } type fakeLLMClient struct{} diff --git a/internal/framework/pipeline/checkpoint.go b/internal/framework/pipeline/checkpoint.go index f19939a..6f21df3 100644 --- a/internal/framework/pipeline/checkpoint.go +++ b/internal/framework/pipeline/checkpoint.go @@ -25,14 +25,14 @@ type CheckpointRecorder interface { ChunkRejected(moduleKey string, sourceDigest string, rejected contracts.RejectedOutput) error ChunkFailed(moduleKey string, sourceDigest string, err error) error ExtractRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - ExtractSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []contracts.ExtractOutput, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error + ExtractSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error ExtractFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error MergeRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - MergeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output contracts.MergeOutput, warnings []contracts.Warning) error + MergeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error MergeRejected(laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error MergeFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error NormalizeRunning(laneID string, moduleKey string, dependencies []CheckpointFingerprint) error - NormalizeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output contracts.NormalizeOutput, warnings []contracts.Warning) error + NormalizeSucceeded(laneID string, moduleKey string, dependencies []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error NormalizeRejected(laneID string, moduleKey string, dependencies []CheckpointFingerprint, rejected contracts.RejectedOutput) error NormalizeFailed(laneID string, moduleKey string, dependencies []CheckpointFingerprint, err error) error } @@ -59,25 +59,9 @@ type ChunkCheckpoint struct { Warnings []contracts.Warning } -type ExtractCheckpoint struct { - Outputs []contracts.ExtractOutput - Rejected []contracts.RejectedOutput - Warnings []contracts.Warning -} - -type MergeCheckpoint struct { - Output contracts.MergeOutput - Warnings []contracts.Warning -} - -type NormalizeCheckpoint struct { - Output contracts.NormalizeOutput - Warnings []contracts.Warning -} - -// ArtifactCheckpointOutput is the durable, domain-neutral value stored at a -// typed lane checkpoint boundary. -type ArtifactCheckpointOutput struct { +// CheckpointArtifact is the durable, domain-neutral value stored at a lane +// checkpoint boundary. +type CheckpointArtifact struct { LaneID string ModuleKey string SourceID string @@ -88,33 +72,21 @@ type ArtifactCheckpointOutput struct { SchemaDigest string } -type ArtifactExtractCheckpoint struct { - Outputs []ArtifactCheckpointOutput +type ExtractCheckpoint struct { + Outputs []CheckpointArtifact Rejected []contracts.RejectedOutput Warnings []contracts.Warning } -type ArtifactMergeCheckpoint struct { - Output ArtifactCheckpointOutput +type MergeCheckpoint struct { + Output CheckpointArtifact Warnings []contracts.Warning } -type ArtifactNormalizeCheckpoint struct { - Output ArtifactCheckpointOutput +type NormalizeCheckpoint struct { + Output CheckpointArtifact Warnings []contracts.Warning } -type ArtifactCheckpointRecorder interface { - ArtifactExtractSucceeded(string, string, []CheckpointFingerprint, []ArtifactCheckpointOutput, []contracts.RejectedOutput, []contracts.Warning) error - ArtifactMergeSucceeded(string, string, []CheckpointFingerprint, ArtifactCheckpointOutput, []contracts.Warning) error - ArtifactNormalizeSucceeded(string, string, []CheckpointFingerprint, ArtifactCheckpointOutput, []contracts.Warning) error -} - -type ArtifactCheckpointLoader interface { - ArtifactExtract(string, string, []CheckpointFingerprint) (ArtifactExtractCheckpoint, CheckpointDecision) - ArtifactMerge(string, string, []CheckpointFingerprint) (ArtifactMergeCheckpoint, CheckpointDecision) - ArtifactNormalize(string, string, []CheckpointFingerprint) (ArtifactNormalizeCheckpoint, CheckpointDecision) -} - type CheckpointLoader interface { Enabled() bool Source(moduleKey string) (SourceCheckpoint, CheckpointDecision) @@ -144,14 +116,14 @@ func (noopCheckpointRecorder) ChunkFailed(string, string, error) error { return func (noopCheckpointRecorder) ExtractRunning(string, string, []CheckpointFingerprint) error { return nil } -func (noopCheckpointRecorder) ExtractSucceeded(string, string, []CheckpointFingerprint, []contracts.ExtractOutput, []contracts.RejectedOutput, []contracts.Warning) error { +func (noopCheckpointRecorder) ExtractSucceeded(string, string, []CheckpointFingerprint, []CheckpointArtifact, []contracts.RejectedOutput, []contracts.Warning) error { return nil } func (noopCheckpointRecorder) ExtractFailed(string, string, []CheckpointFingerprint, error) error { return nil } func (noopCheckpointRecorder) MergeRunning(string, string, []CheckpointFingerprint) error { return nil } -func (noopCheckpointRecorder) MergeSucceeded(string, string, []CheckpointFingerprint, contracts.MergeOutput, []contracts.Warning) error { +func (noopCheckpointRecorder) MergeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact, []contracts.Warning) error { return nil } func (noopCheckpointRecorder) MergeRejected(string, string, []CheckpointFingerprint, contracts.RejectedOutput) error { @@ -163,7 +135,7 @@ func (noopCheckpointRecorder) MergeFailed(string, string, []CheckpointFingerprin func (noopCheckpointRecorder) NormalizeRunning(string, string, []CheckpointFingerprint) error { return nil } -func (noopCheckpointRecorder) NormalizeSucceeded(string, string, []CheckpointFingerprint, contracts.NormalizeOutput, []contracts.Warning) error { +func (noopCheckpointRecorder) NormalizeSucceeded(string, string, []CheckpointFingerprint, CheckpointArtifact, []contracts.Warning) error { return nil } func (noopCheckpointRecorder) NormalizeRejected(string, string, []CheckpointFingerprint, contracts.RejectedOutput) error { @@ -190,28 +162,6 @@ func (noopCheckpointLoader) Normalize(string, string, []CheckpointFingerprint) ( return NormalizeCheckpoint{}, CheckpointDecision{Reason: "checkpoint loading disabled"} } -func rawOutputDigests(payloads []contracts.RawPayload) []CheckpointFingerprint { - values := make([]CheckpointFingerprint, 0, len(payloads)) - for i, payload := range payloads { - values = append(values, CheckpointFingerprint{ - Name: fmt.Sprintf("payload[%d]", i), - Value: checkpointContentDigest(payload.Content), - }) - } - return normalizeCheckpointFingerprints(values) -} - -func extractPayloads(outputs []contracts.ExtractOutput) []contracts.RawPayload { - if len(outputs) == 0 { - return nil - } - payloads := make([]contracts.RawPayload, 0, len(outputs)) - for _, output := range outputs { - payloads = append(payloads, output.Payload) - } - return payloads -} - func digestFingerprints(name string, digest string) []CheckpointFingerprint { digest = strings.TrimSpace(digest) if digest == "" { diff --git a/internal/framework/pipeline/chunker_registry.go b/internal/framework/pipeline/chunker_registry.go index 3d8a4f5..05600eb 100644 --- a/internal/framework/pipeline/chunker_registry.go +++ b/internal/framework/pipeline/chunker_registry.go @@ -32,7 +32,7 @@ func (r *ChunkerRegistry) RegisterWithSpec(spec ModuleSpec, constructor ChunkerC if constructor == nil { return fmt.Errorf("chunker constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return r.RegisterBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.Chunker, error) { + return r.RegisterBuilderWithSpec(spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.Chunker, error) { return constructor() }) } diff --git a/internal/framework/pipeline/chunker_registry_test.go b/internal/framework/pipeline/chunker_registry_test.go index 46ba57e..50ef6b2 100644 --- a/internal/framework/pipeline/chunker_registry_test.go +++ b/internal/framework/pipeline/chunker_registry_test.go @@ -338,34 +338,6 @@ func (chunker registryChunker) Chunk(ctx context.Context, req contracts.ChunkReq return contracts.ChunkResult{}, nil } -type registryMerger struct { - key string -} - -func (merger registryMerger) Key() string { - return merger.key -} - -func (merger registryMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { - return contracts.MergeResult{}, nil -} - -type registryNormalizer struct { - key string -} - -func (normalizer registryNormalizer) Key() string { - return normalizer.key -} - -func (normalizer registryNormalizer) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (normalizer registryNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { - return contracts.NormalizeResult{}, nil -} - type registryOutputEncoder struct { key string } @@ -377,19 +349,3 @@ func (encoder registryOutputEncoder) Key() string { func (encoder registryOutputEncoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { return contracts.OutputResult{}, nil } - -type registryValidator struct { - name string -} - -func (validator registryValidator) Name() string { - return validator.name -} - -func (validator registryValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} - -func (validator registryValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - return contracts.ValidationResult{Approved: true}, nil -} diff --git a/internal/framework/pipeline/construction.go b/internal/framework/pipeline/construction.go index 2021ea8..5dcf9c0 100644 --- a/internal/framework/pipeline/construction.go +++ b/internal/framework/pipeline/construction.go @@ -24,7 +24,9 @@ type BuildRequest struct { // OptionValidator validates one module binding without constructing it. type OptionValidator func(map[string]any) error -func allowLegacyOptions(map[string]any) error { return nil } +func rejectUnconfiguredOptions(options map[string]any) error { + return RejectUnknownOptions(options) +} func validateRegisteredOptions(validator OptionValidator, options map[string]any) error { if validator == nil { diff --git a/internal/framework/pipeline/debug.go b/internal/framework/pipeline/debug.go index d4374e3..5e5a4d9 100644 --- a/internal/framework/pipeline/debug.go +++ b/internal/framework/pipeline/debug.go @@ -82,10 +82,6 @@ type debugBinaryEnvelope struct { Warnings []contracts.Warning `json:"warnings,omitempty"` } -type debugRawPayload struct { - Content debugBinaryEnvelope `json:"content"` -} - type debugSourceInput struct { SourceID string `json:"source_id,omitempty"` Path string `json:"path,omitempty"` @@ -113,32 +109,6 @@ type debugSourceChunk struct { Metadata map[string]any `json:"metadata,omitempty"` } -type debugExtractOutput struct { - LaneID string `json:"lane_id"` - ExtractorKey string `json:"extractor_key"` - SourceID string `json:"source_id"` - ChunkID string `json:"chunk_id"` - ChunkIndex int `json:"chunk_index"` - Schema contracts.ResponseSchema `json:"schema,omitempty"` - Payload debugBinaryEnvelope `json:"payload"` -} - -type debugMergeOutput struct { - LaneID string `json:"lane_id"` - MergerKey string `json:"merger_key"` - SourceID string `json:"source_id,omitempty"` - Schema contracts.ResponseSchema `json:"schema,omitempty"` - Payload debugBinaryEnvelope `json:"payload"` -} - -type debugNormalizeOutput struct { - LaneID string `json:"lane_id"` - NormalizerKey string `json:"normalizer_key"` - SourceID string `json:"source_id,omitempty"` - Schema contracts.ResponseSchema `json:"schema,omitempty"` - Payload debugBinaryEnvelope `json:"payload"` -} - type debugSerializedOutput struct { LaneID string `json:"lane_id"` NormalizerKey string `json:"normalizer_key"` @@ -202,25 +172,6 @@ type debugLLMCallReference struct { Error bool `json:"error,omitempty"` } -type debugValidationRequest struct { - Stage string `json:"stage"` - LaneID string `json:"lane_id,omitempty"` - ModuleKey string `json:"module_key"` - SourceID string `json:"source_id,omitempty"` - SessionID string `json:"session_id,omitempty"` - LLMProfile string `json:"llm_profile,omitempty"` - Options map[string]any `json:"options,omitempty"` - Metadata map[string]any `json:"metadata,omitempty"` - Schema contracts.ResponseSchema `json:"schema,omitempty"` - Payload *debugBinaryEnvelope `json:"payload,omitempty"` - ChunkID string `json:"chunk_id,omitempty"` - ChunkIndex int `json:"chunk_index,omitempty"` - Chunk *debugSourceChunk `json:"chunk,omitempty"` - Chunks []debugSourceChunk `json:"chunks,omitempty"` - ExtractOutputs []debugExtractOutput `json:"extract_outputs,omitempty"` - MergeOutput *debugMergeOutput `json:"merge_output,omitempty"` -} - type debugValidationCall struct { ValidatorName string `json:"validator_name"` Request any `json:"request"` @@ -448,10 +399,6 @@ func debugContentEnvelope(content []byte, mediaType string, metadata map[string] } } -func debugPayloadEnvelope(payload contracts.RawPayload) debugBinaryEnvelope { - return debugContentEnvelope(payload.Content, payload.MediaType, payload.Metadata, payload.Warnings) -} - func debugSourceDocumentEnvelope(doc *source.SourceDocument) *debugSourceDocument { if doc == nil { return nil @@ -489,63 +436,6 @@ func debugSourceChunkEnvelopes(chunks []source.Chunk) []debugSourceChunk { return out } -func debugExtractOutputEnvelope(output contracts.ExtractOutput) debugExtractOutput { - output.Schema.JSONSchema = nil - return debugExtractOutput{ - LaneID: output.LaneID, - ExtractorKey: output.ExtractorKey, - SourceID: output.SourceID, - ChunkID: output.ChunkID, - ChunkIndex: output.ChunkIndex, - Schema: output.Schema, - Payload: debugPayloadEnvelope(output.Payload), - } -} - -func debugExtractOutputEnvelopes(outputs []contracts.ExtractOutput) []debugExtractOutput { - if len(outputs) == 0 { - return nil - } - out := make([]debugExtractOutput, 0, len(outputs)) - for _, output := range outputs { - out = append(out, debugExtractOutputEnvelope(output)) - } - return out -} - -func debugMergeOutputEnvelope(output contracts.MergeOutput) debugMergeOutput { - output.Schema.JSONSchema = nil - return debugMergeOutput{ - LaneID: output.LaneID, - MergerKey: output.MergerKey, - SourceID: output.SourceID, - Schema: output.Schema, - Payload: debugPayloadEnvelope(output.Payload), - } -} - -func debugNormalizeOutputEnvelope(output contracts.NormalizeOutput) debugNormalizeOutput { - output.Schema.JSONSchema = nil - return debugNormalizeOutput{ - LaneID: output.LaneID, - NormalizerKey: output.NormalizerKey, - SourceID: output.SourceID, - Schema: output.Schema, - Payload: debugPayloadEnvelope(output.Payload), - } -} - -func debugNormalizeOutputEnvelopes(outputs []contracts.NormalizeOutput) []debugNormalizeOutput { - if len(outputs) == 0 { - return nil - } - out := make([]debugNormalizeOutput, 0, len(outputs)) - for _, output := range outputs { - out = append(out, debugNormalizeOutputEnvelope(output)) - } - return out -} - func debugSerializedOutputEnvelope(output contracts.SerializedOutput) debugSerializedOutput { schema := contracts.CloneArtifactSchema(output.Artifact.Schema) digest := contracts.DigestArtifactSchema(schema) @@ -714,36 +604,6 @@ func debugResponseModel(response contracts.StructuredCompletionResponse) string return response.Debug.Response.ModelName } -func debugValidationRequestEnvelope(req contracts.ValidationRequest) debugValidationRequest { - req.Schema.JSONSchema = nil - out := debugValidationRequest{ - Stage: req.Stage, - LaneID: req.LaneID, - ModuleKey: req.ModuleKey, - SourceID: req.SourceID, - SessionID: req.SessionID, - LLMProfile: req.LLMProfile, - Options: redactSensitiveMap(req.Options), - Metadata: redactSensitiveMap(req.Metadata), - Schema: req.Schema, - ChunkID: req.ChunkID, - ChunkIndex: req.ChunkIndex, - } - payload := debugPayloadEnvelope(req.Payload) - out.Payload = &payload - if req.Chunk != nil { - chunk := debugSourceChunkEnvelope(*req.Chunk) - out.Chunk = &chunk - } - out.Chunks = debugSourceChunkEnvelopes(req.Chunks) - out.ExtractOutputs = debugExtractOutputEnvelopes(req.ExtractOutputs) - if len(req.MergeOutput.Payload.Content) > 0 || req.MergeOutput.LaneID != "" { - merge := debugMergeOutputEnvelope(req.MergeOutput) - out.MergeOutput = &merge - } - return out -} - func debugValidationResultEnvelope(result contracts.ValidationResult) contracts.ValidationResult { result.Message = string(redactSecretBytes([]byte(result.Message))) result.DiagnosticArtifactPath = string(redactSecretBytes([]byte(result.DiagnosticArtifactPath))) diff --git a/internal/framework/pipeline/default_modules_test.go b/internal/framework/pipeline/default_modules_test.go index 7c65e72..d15c98d 100644 --- a/internal/framework/pipeline/default_modules_test.go +++ b/internal/framework/pipeline/default_modules_test.go @@ -2,6 +2,7 @@ package pipeline_test import ( "context" + "encoding/json" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/config" @@ -71,30 +72,40 @@ func defaultModuleCatalog(t *testing.T) pipeline.ModuleCatalog { if err := units.Register(chunkers); err != nil { t.Fatalf("register generic chunker: %v", err) } - if err := extractors.RegisterLegacyRawWithSpec(pipeline.ModuleSpec{ - Key: "extract", - Stage: pipeline.StageExtract, - Requires: []string{"chunks"}, - Provides: []string{"records"}, - }, func() (contracts.LegacyRawExtractor, error) { + if err := pipeline.RegisterExtractor[defaultArtifact](extractors, pipeline.ModuleSpec{ + Key: "extract", + Stage: pipeline.StageExtract, + ArtifactKind: defaultArtifactKind, + Requires: []string{"chunks"}, + Provides: []string{"records"}, + }, func() (contracts.Extractor[defaultArtifact], error) { return defaultExtractor{}, nil }); err != nil { t.Fatalf("register extractor: %v", err) } - if err := appendorder.Register(mergers); err != nil { + if err := appendorder.RegisterTyped(mergers, defaultArtifactKind, func(values []defaultArtifact) (defaultArtifact, error) { + if len(values) == 0 { + return defaultArtifact{}, nil + } + return values[0], nil + }); err != nil { t.Fatalf("register appendorder merger: %v", err) } - if err := noop.Register(normalizers); err != nil { + if err := noop.RegisterTyped[defaultArtifact](normalizers, defaultArtifactKind); err != nil { t.Fatalf("register noop normalizer: %v", err) } if err := jsonoutput.Register(outputs); err != nil { t.Fatalf("register json output: %v", err) } + codecs := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(codecs, defaultArtifactCodec{}); err != nil { + t.Fatalf("register artifact codec: %v", err) + } return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, - ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), + ArtifactCodecs: codecs, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, @@ -117,6 +128,25 @@ func (defaultExtractor) Key() string { return "extract" } func (defaultExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } -func (defaultExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - return contracts.ExtractionResult{}, nil +func (defaultExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[defaultArtifact], error) { + return contracts.TypedExtractionResult[defaultArtifact]{}, nil +} + +const defaultArtifactKind contracts.ArtifactKind = "test/default" + +type defaultArtifact struct { + Value string `json:"value"` +} +type defaultArtifactCodec struct{} + +func (defaultArtifactCodec) Kind() contracts.ArtifactKind { return defaultArtifactKind } +func (defaultArtifactCodec) Schema() contracts.ArtifactSchema { + return contracts.ArtifactSchema{ID: "urn:notarius:test:default", Name: "default", Version: "1", JSONSchema: []byte(`{"type":"object"}`)} +} +func (defaultArtifactCodec) MediaType() string { return "application/json" } +func (defaultArtifactCodec) Encode(value defaultArtifact) ([]byte, error) { return json.Marshal(value) } +func (defaultArtifactCodec) Decode(content []byte) (defaultArtifact, error) { + var value defaultArtifact + err := json.Unmarshal(content, &value) + return value, err } diff --git a/internal/framework/pipeline/extractor_registry.go b/internal/framework/pipeline/extractor_registry.go index d682cd8..6176d60 100644 --- a/internal/framework/pipeline/extractor_registry.go +++ b/internal/framework/pipeline/extractor_registry.go @@ -9,14 +9,9 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) -type LegacyRawExtractorConstructor func() (contracts.LegacyRawExtractor, error) -type LegacyRawExtractorBuilder func(BuildRequest) (contracts.LegacyRawExtractor, error) - type ExtractorRegistry struct { - legacyBuilders map[string]LegacyRawExtractorBuilder - legacyValidators map[string]OptionValidator - typedEntries map[string]typedExtractorEntry - specs map[string]ModuleSpec + typedEntries map[string]typedExtractorEntry + specs map[string]ModuleSpec } type typedExtractorEntry struct { @@ -25,201 +20,79 @@ type typedExtractorEntry struct { validateOptions OptionValidator builder func(BuildRequest) (any, error) extract typedExtractOperation - rawBuilder LegacyRawExtractorBuilder } func NewExtractorRegistry() *ExtractorRegistry { - return &ExtractorRegistry{ - legacyBuilders: make(map[string]LegacyRawExtractorBuilder), - legacyValidators: make(map[string]OptionValidator), - typedEntries: make(map[string]typedExtractorEntry), - specs: make(map[string]ModuleSpec), - } -} - -func (r *ExtractorRegistry) RegisterLegacyRaw(key string, constructor LegacyRawExtractorConstructor) error { - return r.RegisterLegacyRawWithSpec(defaultModuleSpec(key, StageExtract), constructor) -} - -func (r *ExtractorRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawExtractorConstructor) error { - if constructor == nil { - return fmt.Errorf("extractor constructor for %q must not be nil", strings.TrimSpace(spec.Key)) - } - return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawExtractor, error) { - return constructor() - }) -} - -func (r *ExtractorRegistry) RegisterLegacyRawBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder LegacyRawExtractorBuilder) error { - if r == nil { - return fmt.Errorf("extractor registry must not be nil") - } - normalizedSpec := normalizeModuleSpec(spec) - if err := validateModuleSpec("extractor", StageExtract, normalizedSpec); err != nil { - return err - } - if normalizedSpec.ArtifactKind != "" { - return fmt.Errorf("legacy raw extractor %q must not declare an artifact kind", normalizedSpec.Key) - } - if validateOptions == nil { - return fmt.Errorf("extractor option validator for %q must not be nil", normalizedSpec.Key) - } - if builder == nil { - return fmt.Errorf("extractor builder for %q must not be nil", normalizedSpec.Key) - } - if _, ok := r.specs[normalizedSpec.Key]; ok { - return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key) - } - if r.legacyBuilders == nil { - r.legacyBuilders = make(map[string]LegacyRawExtractorBuilder) - } - if r.legacyValidators == nil { - r.legacyValidators = make(map[string]OptionValidator) - } - if r.specs == nil { - r.specs = make(map[string]ModuleSpec) - } - r.legacyBuilders[normalizedSpec.Key] = builder - r.legacyValidators[normalizedSpec.Key] = validateOptions - r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) - return nil + return &ExtractorRegistry{typedEntries: map[string]typedExtractorEntry{}, specs: map[string]ModuleSpec{}} } func RegisterExtractor[T any](registry *ExtractorRegistry, spec ModuleSpec, constructor func() (contracts.Extractor[T], error)) error { if constructor == nil { return fmt.Errorf("extractor constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return RegisterExtractorBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.Extractor[T], error) { - return constructor() - }) + return RegisterExtractorBuilder(registry, spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.Extractor[T], error) { return constructor() }) } func RegisterExtractorBuilder[T any](registry *ExtractorRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Extractor[T], error)) error { - return registerExtractorBuilder(registry, spec, validateOptions, builder, nil) -} - -// RegisterExtractorBuilderWithRawAdapter registers a typed extractor while a -// raw downstream remains in use. Resolution selects the adapter until the -// registration is replaced with the typed-only builder. -func RegisterExtractorBuilderWithRawAdapter[T any](registry *ExtractorRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Extractor[T], error), rawBuilder LegacyRawExtractorBuilder) error { - if rawBuilder == nil { - return fmt.Errorf("extractor raw adapter builder for %q must not be nil", strings.TrimSpace(spec.Key)) - } - return registerExtractorBuilder(registry, spec, validateOptions, builder, rawBuilder) -} - -func registerExtractorBuilder[T any](registry *ExtractorRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Extractor[T], error), rawBuilder LegacyRawExtractorBuilder) error { if registry == nil { return fmt.Errorf("extractor registry must not be nil") } - normalizedSpec := normalizeModuleSpec(spec) - if err := validateModuleSpec("extractor", StageExtract, normalizedSpec); err != nil { + normalized := normalizeModuleSpec(spec) + if err := validateModuleSpec("extractor", StageExtract, normalized); err != nil { return err } - if normalizedSpec.ArtifactKind == "" { - return fmt.Errorf("typed extractor %q artifact kind must not be empty", normalizedSpec.Key) + if normalized.ArtifactKind == "" { + return fmt.Errorf("typed extractor %q artifact kind must not be empty", normalized.Key) } if validateOptions == nil { - return fmt.Errorf("extractor option validator for %q must not be nil", normalizedSpec.Key) + return fmt.Errorf("extractor option validator for %q must not be nil", normalized.Key) } if builder == nil { - return fmt.Errorf("extractor builder for %q must not be nil", normalizedSpec.Key) + return fmt.Errorf("extractor builder for %q must not be nil", normalized.Key) } - if _, ok := registry.specs[normalizedSpec.Key]; ok { - return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key) - } - entry := typedExtractorEntry{ - spec: cloneModuleSpec(normalizedSpec), - valueType: reflect.TypeFor[T](), - validateOptions: validateOptions, - builder: func(request BuildRequest) (any, error) { - return builder(cloneBuildRequest(request)) - }, - extract: func(ctx context.Context, implementation any, request contracts.TypedExtractionRequest) (erasedTypedResult, error) { - extractor, ok := implementation.(contracts.Extractor[T]) - if !ok { - return erasedTypedResult{}, fmt.Errorf("extractor %q has incompatible implementation %T", normalizedSpec.Key, implementation) - } - result, err := extractor.Extract(ctx, request) - if err != nil { - return erasedTypedResult{}, err - } - return erasedTypedResult{Value: result.Value, Warnings: result.Warnings}, nil - }, - rawBuilder: rawBuilder, + if _, ok := registry.specs[normalized.Key]; ok { + return fmt.Errorf("extractor %q is already registered", normalized.Key) } + entry := typedExtractorEntry{spec: cloneModuleSpec(normalized), valueType: reflect.TypeFor[T](), validateOptions: validateOptions, builder: func(request BuildRequest) (any, error) { return builder(cloneBuildRequest(request)) }, extract: func(ctx context.Context, implementation any, request contracts.TypedExtractionRequest) (erasedTypedResult, error) { + extractor, ok := implementation.(contracts.Extractor[T]) + if !ok { + return erasedTypedResult{}, fmt.Errorf("extractor %q has incompatible implementation %T", normalized.Key, implementation) + } + result, err := extractor.Extract(ctx, request) + if err != nil { + return erasedTypedResult{}, err + } + return erasedTypedResult{Value: result.Value, Warnings: result.Warnings}, nil + }} if registry.typedEntries == nil { - registry.typedEntries = make(map[string]typedExtractorEntry) + registry.typedEntries = map[string]typedExtractorEntry{} } if registry.specs == nil { - registry.specs = make(map[string]ModuleSpec) + registry.specs = map[string]ModuleSpec{} } - registry.typedEntries[normalizedSpec.Key] = entry - registry.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) + registry.typedEntries[normalized.Key] = entry + registry.specs[normalized.Key] = cloneModuleSpec(normalized) return nil } -func (r *ExtractorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawExtractor, error) { - return r.BuildLegacyRawWithRequest(key, BuildRequest{}) -} - -func (r *ExtractorRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawExtractor, error) { - if r == nil { - return nil, fmt.Errorf("extractor registry must not be nil") - } - normalizedKey := strings.TrimSpace(key) - if normalizedKey == "" { - return nil, fmt.Errorf("extractor key must not be empty") - } - builder, ok := r.legacyBuilders[normalizedKey] - if !ok { - if entry, typedOK := r.typedEntries[normalizedKey]; typedOK && entry.rawBuilder != nil { - builder = entry.rawBuilder - ok = true - } - } - if !ok { - return nil, fmt.Errorf("legacy raw extractor %q is not registered", normalizedKey) - } - extractor, err := builder(cloneBuildRequest(request)) - if err != nil { - return nil, fmt.Errorf("build extractor %q: %w", normalizedKey, err) - } - if extractor == nil { - return nil, fmt.Errorf("extractor %q constructor returned nil", normalizedKey) - } - if extractor.Key() != normalizedKey { - return nil, fmt.Errorf("extractor %q returned key %q", normalizedKey, extractor.Key()) - } - return extractor, nil -} - func (r *ExtractorRegistry) validateOptions(key string, options map[string]any) error { if r == nil { return fmt.Errorf("extractor registry must not be nil") } - normalizedKey := strings.TrimSpace(key) - if entry, ok := r.typedEntries[normalizedKey]; ok { - return validateRegisteredOptions(entry.validateOptions, options) - } - validator, ok := r.legacyValidators[normalizedKey] + normalized := strings.TrimSpace(key) + entry, ok := r.typedEntries[normalized] if !ok { - return fmt.Errorf("extractor %q is not registered", normalizedKey) + return fmt.Errorf("extractor %q is not registered", normalized) } - return validateRegisteredOptions(validator, options) + return validateRegisteredOptions(entry.validateOptions, options) } - func (r *ExtractorRegistry) Spec(key string) (ModuleSpec, bool) { if r == nil { return ModuleSpec{}, false } spec, ok := r.specs[strings.TrimSpace(key)] - if !ok { - return ModuleSpec{}, false - } - return cloneModuleSpec(spec), true + return cloneModuleSpec(spec), ok } - func (r *ExtractorRegistry) typedEntry(key string) (typedExtractorEntry, bool) { if r == nil { return typedExtractorEntry{}, false @@ -227,12 +100,6 @@ func (r *ExtractorRegistry) typedEntry(key string) (typedExtractorEntry, bool) { entry, ok := r.typedEntries[strings.TrimSpace(key)] return entry, ok } - -func (r *ExtractorRegistry) usesRawAdapter(key string) bool { - entry, ok := r.typedEntry(key) - return ok && entry.rawBuilder != nil -} - func (r *ExtractorRegistry) RegisteredKeys() []string { if r == nil { return nil diff --git a/internal/framework/pipeline/extractor_registry_test.go b/internal/framework/pipeline/extractor_registry_test.go deleted file mode 100644 index 7992387..0000000 --- a/internal/framework/pipeline/extractor_registry_test.go +++ /dev/null @@ -1,414 +0,0 @@ -package pipeline - -import ( - "context" - "errors" - "reflect" - "strings" - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -func TestExtractorRegistryRegisterAndBuild(t *testing.T) { - registry := NewExtractorRegistry() - - if err := registry.RegisterLegacyRaw("generic-extractor", fakeExtractorConstructor("generic-extractor")); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - extractor, err := registry.BuildLegacyRaw("generic-extractor") - if err != nil { - t.Fatalf("Build() error = %v, want nil", err) - } - if extractor.Key() != "generic-extractor" { - t.Fatalf("extractor.Key() = %q, want generic-extractor", extractor.Key()) - } -} - -func TestExtractorRegistryRegisterAndBuildTrimKeys(t *testing.T) { - registry := NewExtractorRegistry() - - if err := registry.RegisterLegacyRaw(" generic-extractor ", fakeExtractorConstructor("generic-extractor")); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - extractor, err := registry.BuildLegacyRaw("\tgeneric-extractor\n") - if err != nil { - t.Fatalf("Build() error = %v, want nil", err) - } - if extractor.Key() != "generic-extractor" { - t.Fatalf("extractor.Key() = %q, want generic-extractor", extractor.Key()) - } -} - -func TestExtractorRegistryRegisterWithSpecStoresMetadata(t *testing.T) { - registry := NewExtractorRegistry() - spec := ModuleSpec{ - Key: " generic-extractor ", - Stage: StageExtract, - Provides: []string{" generic-artifact ", "source-citations", "generic-artifact", ""}, - Requires: []string{" source-document ", "source-document", ""}, - ReferenceSlots: []contracts.ReferenceSlot{ - { - Name: " glossary ", - Description: " Supporting terms ", - AcceptedMediaTypes: []string{" text/plain ", "text/markdown", "text/plain", ""}, - MaxBytes: 1024, - }, - { - Name: " roster ", - Description: " Characters ", - Required: true, - Multiple: true, - }, - }, - } - - if err := registry.RegisterLegacyRawWithSpec(spec, fakeExtractorConstructor("generic-extractor")); err != nil { - t.Fatalf("RegisterWithSpec() error = %v, want nil", err) - } - - got, ok := registry.Spec("\tgeneric-extractor\n") - if !ok { - t.Fatal("Spec() ok = false, want true") - } - want := ModuleSpec{ - Key: "generic-extractor", - Stage: StageExtract, - Provides: []string{"generic-artifact", "source-citations"}, - Requires: []string{"source-document"}, - ReferenceSlots: []contracts.ReferenceSlot{ - { - Name: "glossary", - Description: "Supporting terms", - AcceptedMediaTypes: []string{"text/markdown", "text/plain"}, - MaxBytes: 1024, - }, - { - Name: "roster", - Description: "Characters", - Required: true, - Multiple: true, - }, - }, - } - if !reflect.DeepEqual(got, want) { - t.Fatalf("Spec() = %#v, want %#v", got, want) - } - - got.Provides[0] = "changed" - got.ReferenceSlots[0].Name = "changed" - got.ReferenceSlots[0].AcceptedMediaTypes[0] = "changed" - again, ok := registry.Spec("generic-extractor") - if !ok { - t.Fatal("Spec() after caller mutation ok = false, want true") - } - if !reflect.DeepEqual(again, want) { - t.Fatalf("Spec() after caller mutation = %#v, want %#v", again, want) - } -} - -func TestExtractorRegistryRegisterStoresDefaultSpec(t *testing.T) { - registry := NewExtractorRegistry() - - if err := registry.RegisterLegacyRaw(" generic-extractor ", fakeExtractorConstructor("generic-extractor")); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - got, ok := registry.Spec("generic-extractor") - if !ok { - t.Fatal("Spec() ok = false, want true") - } - want := ModuleSpec{Key: "generic-extractor", Stage: StageExtract} - if !reflect.DeepEqual(got, want) { - t.Fatalf("Spec() = %#v, want %#v", got, want) - } -} - -func TestExtractorRegistryRegisterWithSpecRejectsWrongStage(t *testing.T) { - registry := NewExtractorRegistry() - - err := registry.RegisterLegacyRawWithSpec(ModuleSpec{Key: "generic-extractor", Stage: StageInput}, fakeExtractorConstructor("generic-extractor")) - - if err == nil { - t.Fatal("RegisterWithSpec() error = nil, want error") - } - if !strings.Contains(err.Error(), "stage") { - t.Fatalf("RegisterWithSpec() error = %q, want stage error", err.Error()) - } -} - -func TestExtractorRegistryRejectsInvalidReferenceSlots(t *testing.T) { - tests := []struct { - name string - slots []contracts.ReferenceSlot - want string - }{ - { - name: "empty name", - slots: []contracts.ReferenceSlot{{Name: " "}}, - want: "name", - }, - { - name: "duplicate name after trim", - slots: []contracts.ReferenceSlot{ - {Name: "roster"}, - {Name: " roster "}, - }, - want: "duplicated", - }, - { - name: "negative max bytes", - slots: []contracts.ReferenceSlot{{Name: "roster", MaxBytes: -1}}, - want: "max_bytes", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - registry := NewExtractorRegistry() - err := registry.RegisterLegacyRawWithSpec(ModuleSpec{ - Key: "generic-extractor", - Stage: StageExtract, - ReferenceSlots: test.slots, - }, fakeExtractorConstructor("generic-extractor")) - if err == nil { - t.Fatal("RegisterWithSpec() error = nil, want error") - } - if !strings.Contains(err.Error(), test.want) { - t.Fatalf("RegisterWithSpec() error = %q, want %q", err.Error(), test.want) - } - }) - } -} - -func TestExtractorRegistrySpecRejectsUnknownKey(t *testing.T) { - registry := NewExtractorRegistry() - - if _, ok := registry.Spec("missing-extractor"); ok { - t.Fatal("Spec() ok = true, want false") - } -} - -func TestExtractorRegistryRegisterRejectsEmptyKey(t *testing.T) { - registry := NewExtractorRegistry() - - err := registry.RegisterLegacyRaw(" \t", fakeExtractorConstructor("generic-extractor")) - - if err == nil { - t.Fatal("Register() error = nil, want error") - } - if !strings.Contains(err.Error(), "key must not be empty") { - t.Fatalf("Register() error = %q, want empty key error", err.Error()) - } -} - -func TestExtractorRegistryRegisterRejectsDuplicateKey(t *testing.T) { - registry := NewExtractorRegistry() - if err := registry.RegisterLegacyRaw("generic-extractor", fakeExtractorConstructor("generic-extractor")); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - err := registry.RegisterLegacyRaw(" generic-extractor ", fakeExtractorConstructor("generic-extractor")) - - if err == nil { - t.Fatal("Register() error = nil, want error") - } - if !strings.Contains(err.Error(), "already registered") { - t.Fatalf("Register() error = %q, want duplicate key error", err.Error()) - } -} - -func TestExtractorRegistryRegisterRejectsNilConstructor(t *testing.T) { - registry := NewExtractorRegistry() - - err := registry.RegisterLegacyRaw("generic-extractor", nil) - - if err == nil { - t.Fatal("Register() error = nil, want error") - } - if !strings.Contains(err.Error(), "constructor") { - t.Fatalf("Register() error = %q, want constructor error", err.Error()) - } -} - -func TestExtractorRegistryBuildRejectsUnknownKey(t *testing.T) { - registry := NewExtractorRegistry() - - _, err := registry.BuildLegacyRaw("missing-extractor") - - if err == nil { - t.Fatal("Build() error = nil, want error") - } - if !strings.Contains(err.Error(), "not registered") { - t.Fatalf("Build() error = %q, want unknown key error", err.Error()) - } -} - -func TestExtractorRegistryBuildWrapsConstructorError(t *testing.T) { - registry := NewExtractorRegistry() - constructorErr := errors.New("constructor failed") - if err := registry.RegisterLegacyRaw("generic-extractor", func() (contracts.LegacyRawExtractor, error) { - return nil, constructorErr - }); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - _, err := registry.BuildLegacyRaw("generic-extractor") - - if err == nil { - t.Fatal("Build() error = nil, want error") - } - if !errors.Is(err, constructorErr) { - t.Fatalf("Build() error = %v, want wrapped constructor error", err) - } - if !strings.Contains(err.Error(), "generic-extractor") { - t.Fatalf("Build() error = %q, want key context", err.Error()) - } -} - -func TestExtractorRegistryBuildRejectsNilExtractor(t *testing.T) { - registry := NewExtractorRegistry() - if err := registry.RegisterLegacyRaw("generic-extractor", func() (contracts.LegacyRawExtractor, error) { - return nil, nil - }); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - _, err := registry.BuildLegacyRaw("generic-extractor") - - if err == nil { - t.Fatal("Build() error = nil, want error") - } - if !strings.Contains(err.Error(), "returned nil") { - t.Fatalf("Build() error = %q, want nil extractor error", err.Error()) - } -} - -func TestExtractorRegistryBuildRejectsExtractorKeyMismatch(t *testing.T) { - registry := NewExtractorRegistry() - if err := registry.RegisterLegacyRaw("generic-extractor", fakeExtractorConstructor("other-extractor")); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - _, err := registry.BuildLegacyRaw("generic-extractor") - - if err == nil { - t.Fatal("Build() error = nil, want error") - } - if !strings.Contains(err.Error(), "returned key") { - t.Fatalf("Build() error = %q, want key mismatch error", err.Error()) - } -} - -func TestExtractorRegistryRegisteredKeysReturnsSortedCopy(t *testing.T) { - registry := NewExtractorRegistry() - for _, key := range []string{"zeta", "alpha", "middle"} { - if err := registry.RegisterLegacyRaw(key, fakeExtractorConstructor(key)); err != nil { - t.Fatalf("Register(%q) error = %v, want nil", key, err) - } - } - - keys := registry.RegisteredKeys() - - want := []string{"alpha", "middle", "zeta"} - if !reflect.DeepEqual(keys, want) { - t.Fatalf("RegisteredKeys() = %#v, want %#v", keys, want) - } - - keys[0] = "changed" - if got := registry.RegisteredKeys(); !reflect.DeepEqual(got, want) { - t.Fatalf("RegisteredKeys() after caller mutation = %#v, want %#v", got, want) - } -} - -func TestExtractorRegistryNilRegistryBehavior(t *testing.T) { - var registry *ExtractorRegistry - - if err := registry.RegisterLegacyRaw("generic-extractor", fakeExtractorConstructor("generic-extractor")); err == nil { - t.Fatal("Register() error = nil, want error") - } - if _, err := registry.BuildLegacyRaw("generic-extractor"); err == nil { - t.Fatal("Build() error = nil, want error") - } - if _, ok := registry.Spec("generic-extractor"); ok { - t.Fatal("Spec() ok = true, want false") - } - if keys := registry.RegisteredKeys(); keys != nil { - t.Fatalf("RegisteredKeys() = %#v, want nil", keys) - } -} - -func TestExtractorRegistryBuildRejectsEmptyKey(t *testing.T) { - registry := NewExtractorRegistry() - - _, err := registry.BuildLegacyRaw(" \n") - - if err == nil { - t.Fatal("Build() error = nil, want error") - } - if !strings.Contains(err.Error(), "key must not be empty") { - t.Fatalf("Build() error = %q, want empty key error", err.Error()) - } -} - -func TestExtractorRegistryTypedRegistrationCanProvideRawAdapter(t *testing.T) { - registry := NewExtractorRegistry() - spec := ModuleSpec{Key: "typed-extractor", Stage: StageExtract, ArtifactKind: "test/value"} - if err := RegisterExtractorBuilderWithRawAdapter(registry, spec, func(map[string]any) error { return nil }, - func(BuildRequest) (contracts.Extractor[registryTypedValue], error) { - return registryTypedExtractor{key: spec.Key}, nil - }, - func(BuildRequest) (contracts.LegacyRawExtractor, error) { - return registryFakeExtractor{key: spec.Key}, nil - }, - ); err != nil { - t.Fatalf("RegisterExtractorBuilderWithRawAdapter() error = %v", err) - } - if !registry.usesRawAdapter(spec.Key) { - t.Fatal("usesRawAdapter() = false, want true") - } - if _, ok := registry.typedEntry(spec.Key); !ok { - t.Fatal("typedEntry() ok = false, want true") - } - adapter, err := registry.BuildLegacyRaw(spec.Key) - if err != nil || adapter.Key() != spec.Key { - t.Fatalf("BuildLegacyRaw() = %#v, %v", adapter, err) - } - if err := RegisterExtractorBuilderWithRawAdapter[registryTypedValue](NewExtractorRegistry(), spec, func(map[string]any) error { return nil }, nil, nil); err == nil || !strings.Contains(err.Error(), "raw adapter") { - t.Fatalf("nil raw builder error = %v, want raw adapter context", err) - } -} - -type registryFakeExtractor struct { - key string -} - -type registryTypedValue struct{ Value string } - -type registryTypedExtractor struct{ key string } - -func (extractor registryTypedExtractor) Key() string { return extractor.key } -func (registryTypedExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } -func (registryTypedExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[registryTypedValue], error) { - return contracts.TypedExtractionResult[registryTypedValue]{}, nil -} - -func fakeExtractorConstructor(key string) LegacyRawExtractorConstructor { - return func() (contracts.LegacyRawExtractor, error) { - return registryFakeExtractor{key: key}, nil - } -} - -func (extractor registryFakeExtractor) Key() string { - return extractor.key -} - -func (extractor registryFakeExtractor) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (extractor registryFakeExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - return contracts.ExtractionResult{}, nil -} diff --git a/internal/framework/pipeline/input_registry.go b/internal/framework/pipeline/input_registry.go index 14a79e7..4bfa161 100644 --- a/internal/framework/pipeline/input_registry.go +++ b/internal/framework/pipeline/input_registry.go @@ -32,7 +32,7 @@ func (r *InputAdapterRegistry) RegisterWithSpec(spec ModuleSpec, constructor Inp if constructor == nil { return fmt.Errorf("input adapter constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return r.RegisterBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.InputAdapter, error) { + return r.RegisterBuilderWithSpec(spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.InputAdapter, error) { return constructor() }) } diff --git a/internal/framework/pipeline/merger_registry.go b/internal/framework/pipeline/merger_registry.go index 26501e6..5d4a730 100644 --- a/internal/framework/pipeline/merger_registry.go +++ b/internal/framework/pipeline/merger_registry.go @@ -9,19 +9,13 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) -type LegacyRawMergerConstructor func() (contracts.LegacyRawMerger, error) -type LegacyRawMergerBuilder func(BuildRequest) (contracts.LegacyRawMerger, error) - type artifactVariantKey struct { module string kind contracts.ArtifactKind } type MergerRegistry struct { - legacyBuilders map[string]LegacyRawMergerBuilder - legacyValidators map[string]OptionValidator - legacySpecs map[string]ModuleSpec - typedEntries map[artifactVariantKey]typedMergerEntry + typedEntries map[artifactVariantKey]typedMergerEntry } type typedMergerEntry struct { @@ -34,66 +28,15 @@ type typedMergerEntry struct { func NewMergerRegistry() *MergerRegistry { return &MergerRegistry{ - legacyBuilders: make(map[string]LegacyRawMergerBuilder), - legacyValidators: make(map[string]OptionValidator), - legacySpecs: make(map[string]ModuleSpec), - typedEntries: make(map[artifactVariantKey]typedMergerEntry), + typedEntries: make(map[artifactVariantKey]typedMergerEntry), } } -func (r *MergerRegistry) RegisterLegacyRaw(key string, constructor LegacyRawMergerConstructor) error { - return r.RegisterLegacyRawWithSpec(defaultModuleSpec(key, StageMerge), constructor) -} - -func (r *MergerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawMergerConstructor) error { - if constructor == nil { - return fmt.Errorf("merger constructor for %q must not be nil", strings.TrimSpace(spec.Key)) - } - return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawMerger, error) { - return constructor() - }) -} - -func (r *MergerRegistry) RegisterLegacyRawBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder LegacyRawMergerBuilder) error { - if r == nil { - return fmt.Errorf("merger registry must not be nil") - } - normalizedSpec := normalizeModuleSpec(spec) - if err := validateModuleSpec("merger", StageMerge, normalizedSpec); err != nil { - return err - } - if normalizedSpec.ArtifactKind != "" { - return fmt.Errorf("legacy raw merger %q must not declare an artifact kind", normalizedSpec.Key) - } - if validateOptions == nil { - return fmt.Errorf("merger option validator for %q must not be nil", normalizedSpec.Key) - } - if builder == nil { - return fmt.Errorf("merger builder for %q must not be nil", normalizedSpec.Key) - } - if _, ok := r.legacyBuilders[normalizedSpec.Key]; ok { - return fmt.Errorf("legacy raw merger %q is already registered", normalizedSpec.Key) - } - if r.legacyBuilders == nil { - r.legacyBuilders = make(map[string]LegacyRawMergerBuilder) - } - if r.legacyValidators == nil { - r.legacyValidators = make(map[string]OptionValidator) - } - if r.legacySpecs == nil { - r.legacySpecs = make(map[string]ModuleSpec) - } - r.legacyBuilders[normalizedSpec.Key] = builder - r.legacyValidators[normalizedSpec.Key] = validateOptions - r.legacySpecs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) - return nil -} - func RegisterMerger[T any](registry *MergerRegistry, spec ModuleSpec, constructor func() (contracts.Merger[T], error)) error { if constructor == nil { return fmt.Errorf("merger constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return RegisterMergerBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.Merger[T], error) { + return RegisterMergerBuilder(registry, spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.Merger[T], error) { return constructor() }) } @@ -152,63 +95,29 @@ func RegisterMergerBuilder[T any](registry *MergerRegistry, spec ModuleSpec, val return nil } -func (r *MergerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawMerger, error) { - return r.BuildLegacyRawWithRequest(key, BuildRequest{}) -} - -func (r *MergerRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawMerger, error) { - if r == nil { - return nil, fmt.Errorf("merger registry must not be nil") - } - normalizedKey := strings.TrimSpace(key) - if normalizedKey == "" { - return nil, fmt.Errorf("merger key must not be empty") - } - builder, ok := r.legacyBuilders[normalizedKey] - if !ok { - return nil, fmt.Errorf("legacy raw merger %q is not registered", normalizedKey) - } - merger, err := builder(cloneBuildRequest(request)) - if err != nil { - return nil, fmt.Errorf("build merger %q: %w", normalizedKey, err) - } - if merger == nil { - return nil, fmt.Errorf("merger %q constructor returned nil", normalizedKey) - } - if merger.Key() != normalizedKey { - return nil, fmt.Errorf("merger %q returned key %q", normalizedKey, merger.Key()) - } - return merger, nil -} - func (r *MergerRegistry) validateOptions(key string, kind contracts.ArtifactKind, options map[string]any) error { if r == nil { return fmt.Errorf("merger registry must not be nil") } normalizedKey := strings.TrimSpace(key) - if kind != "" { - entry, ok := r.typedEntry(normalizedKey, kind) - if !ok { - return fmt.Errorf("merger %q variant for artifact kind %q is not registered", normalizedKey, kind) - } - return validateRegisteredOptions(entry.validateOptions, options) - } - validator, ok := r.legacyValidators[normalizedKey] + entry, ok := r.typedEntry(normalizedKey, kind) if !ok { - return fmt.Errorf("legacy raw merger %q is not registered", normalizedKey) + return fmt.Errorf("merger %q variant for artifact kind %q is not registered", normalizedKey, kind) } - return validateRegisteredOptions(validator, options) + return validateRegisteredOptions(entry.validateOptions, options) } func (r *MergerRegistry) Spec(key string) (ModuleSpec, bool) { if r == nil { return ModuleSpec{}, false } - spec, ok := r.legacySpecs[strings.TrimSpace(key)] - if !ok { - return ModuleSpec{}, false + module := strings.TrimSpace(key) + for variant, entry := range r.typedEntries { + if variant.module == module { + return cloneModuleSpec(entry.spec), true + } } - return cloneModuleSpec(spec), true + return ModuleSpec{}, false } func (r *MergerRegistry) typedEntry(key string, kind contracts.ArtifactKind) (typedMergerEntry, bool) { @@ -238,10 +147,7 @@ func (r *MergerRegistry) RegisteredKeys() []string { if r == nil { return nil } - keys := make(map[string]struct{}, len(r.legacySpecs)+len(r.typedEntries)) - for key := range r.legacySpecs { - keys[key] = struct{}{} - } + keys := make(map[string]struct{}, len(r.typedEntries)) for key := range r.typedEntries { keys[key.module] = struct{}{} } diff --git a/internal/framework/pipeline/merger_registry_test.go b/internal/framework/pipeline/merger_registry_test.go deleted file mode 100644 index 2e770f3..0000000 --- a/internal/framework/pipeline/merger_registry_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package pipeline - -import ( - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -func TestMergerRegistryBehavior(t *testing.T) { - runRegistryBehaviorTests(t, registryBehaviorCase[contracts.LegacyRawMerger]{ - name: "MergerRegistry", - key: "generic-merger", - stage: StageMerge, - wrongStage: StageExtract, - newRegistry: func() any { - return NewMergerRegistry() - }, - register: func(registry any, key string, constructor func() (contracts.LegacyRawMerger, error)) error { - return registry.(*MergerRegistry).RegisterLegacyRaw(key, constructor) - }, - registerWithSpec: func(registry any, spec ModuleSpec, constructor func() (contracts.LegacyRawMerger, error)) error { - return registry.(*MergerRegistry).RegisterLegacyRawWithSpec(spec, constructor) - }, - build: func(registry any, key string) (contracts.LegacyRawMerger, error) { - return registry.(*MergerRegistry).BuildLegacyRaw(key) - }, - spec: func(registry any, key string) (ModuleSpec, bool) { - return registry.(*MergerRegistry).Spec(key) - }, - registeredKeys: func(registry any) []string { - return registry.(*MergerRegistry).RegisteredKeys() - }, - nilRegister: func(key string, constructor func() (contracts.LegacyRawMerger, error)) error { - var registry *MergerRegistry - return registry.RegisterLegacyRaw(key, constructor) - }, - nilBuild: func(key string) (contracts.LegacyRawMerger, error) { - var registry *MergerRegistry - return registry.BuildLegacyRaw(key) - }, - nilSpec: func(key string) (ModuleSpec, bool) { - var registry *MergerRegistry - return registry.Spec(key) - }, - nilRegisteredKey: func() []string { - var registry *MergerRegistry - return registry.RegisteredKeys() - }, - constructor: func(key string) func() (contracts.LegacyRawMerger, error) { - return func() (contracts.LegacyRawMerger, error) { - return registryMerger{key: key}, nil - } - }, - moduleKey: func(module contracts.LegacyRawMerger) string { - return module.Key() - }, - }) -} diff --git a/internal/framework/pipeline/normalizer_registry.go b/internal/framework/pipeline/normalizer_registry.go index 5a73628..ea324e9 100644 --- a/internal/framework/pipeline/normalizer_registry.go +++ b/internal/framework/pipeline/normalizer_registry.go @@ -9,14 +9,8 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) -type LegacyRawNormalizerConstructor func() (contracts.LegacyRawNormalizer, error) -type LegacyRawNormalizerBuilder func(BuildRequest) (contracts.LegacyRawNormalizer, error) - type NormalizerRegistry struct { - legacyBuilders map[string]LegacyRawNormalizerBuilder - legacyValidators map[string]OptionValidator - legacySpecs map[string]ModuleSpec - typedEntries map[artifactVariantKey]typedNormalizerEntry + typedEntries map[artifactVariantKey]typedNormalizerEntry } type typedNormalizerEntry struct { @@ -29,66 +23,15 @@ type typedNormalizerEntry struct { func NewNormalizerRegistry() *NormalizerRegistry { return &NormalizerRegistry{ - legacyBuilders: make(map[string]LegacyRawNormalizerBuilder), - legacyValidators: make(map[string]OptionValidator), - legacySpecs: make(map[string]ModuleSpec), - typedEntries: make(map[artifactVariantKey]typedNormalizerEntry), + typedEntries: make(map[artifactVariantKey]typedNormalizerEntry), } } -func (r *NormalizerRegistry) RegisterLegacyRaw(key string, constructor LegacyRawNormalizerConstructor) error { - return r.RegisterLegacyRawWithSpec(defaultModuleSpec(key, StageNormalize), constructor) -} - -func (r *NormalizerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor LegacyRawNormalizerConstructor) error { - if constructor == nil { - return fmt.Errorf("normalizer constructor for %q must not be nil", strings.TrimSpace(spec.Key)) - } - return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawNormalizer, error) { - return constructor() - }) -} - -func (r *NormalizerRegistry) RegisterLegacyRawBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder LegacyRawNormalizerBuilder) error { - if r == nil { - return fmt.Errorf("normalizer registry must not be nil") - } - normalizedSpec := normalizeModuleSpec(spec) - if err := validateModuleSpec("normalizer", StageNormalize, normalizedSpec); err != nil { - return err - } - if normalizedSpec.ArtifactKind != "" { - return fmt.Errorf("legacy raw normalizer %q must not declare an artifact kind", normalizedSpec.Key) - } - if validateOptions == nil { - return fmt.Errorf("normalizer option validator for %q must not be nil", normalizedSpec.Key) - } - if builder == nil { - return fmt.Errorf("normalizer builder for %q must not be nil", normalizedSpec.Key) - } - if _, ok := r.legacyBuilders[normalizedSpec.Key]; ok { - return fmt.Errorf("legacy raw normalizer %q is already registered", normalizedSpec.Key) - } - if r.legacyBuilders == nil { - r.legacyBuilders = make(map[string]LegacyRawNormalizerBuilder) - } - if r.legacyValidators == nil { - r.legacyValidators = make(map[string]OptionValidator) - } - if r.legacySpecs == nil { - r.legacySpecs = make(map[string]ModuleSpec) - } - r.legacyBuilders[normalizedSpec.Key] = builder - r.legacyValidators[normalizedSpec.Key] = validateOptions - r.legacySpecs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec) - return nil -} - func RegisterNormalizer[T any](registry *NormalizerRegistry, spec ModuleSpec, constructor func() (contracts.Normalizer[T], error)) error { if constructor == nil { return fmt.Errorf("normalizer constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return RegisterNormalizerBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.Normalizer[T], error) { + return RegisterNormalizerBuilder(registry, spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.Normalizer[T], error) { return constructor() }) } @@ -143,63 +86,29 @@ func RegisterNormalizerBuilder[T any](registry *NormalizerRegistry, spec ModuleS return nil } -func (r *NormalizerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawNormalizer, error) { - return r.BuildLegacyRawWithRequest(key, BuildRequest{}) -} - -func (r *NormalizerRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawNormalizer, error) { - if r == nil { - return nil, fmt.Errorf("normalizer registry must not be nil") - } - normalizedKey := strings.TrimSpace(key) - if normalizedKey == "" { - return nil, fmt.Errorf("normalizer key must not be empty") - } - builder, ok := r.legacyBuilders[normalizedKey] - if !ok { - return nil, fmt.Errorf("legacy raw normalizer %q is not registered", normalizedKey) - } - normalizer, err := builder(cloneBuildRequest(request)) - if err != nil { - return nil, fmt.Errorf("build normalizer %q: %w", normalizedKey, err) - } - if normalizer == nil { - return nil, fmt.Errorf("normalizer %q constructor returned nil", normalizedKey) - } - if normalizer.Key() != normalizedKey { - return nil, fmt.Errorf("normalizer %q returned key %q", normalizedKey, normalizer.Key()) - } - return normalizer, nil -} - func (r *NormalizerRegistry) validateOptions(key string, kind contracts.ArtifactKind, options map[string]any) error { if r == nil { return fmt.Errorf("normalizer registry must not be nil") } normalizedKey := strings.TrimSpace(key) - if kind != "" { - entry, ok := r.typedEntry(normalizedKey, kind) - if !ok { - return fmt.Errorf("normalizer %q variant for artifact kind %q is not registered", normalizedKey, kind) - } - return validateRegisteredOptions(entry.validateOptions, options) - } - validator, ok := r.legacyValidators[normalizedKey] + entry, ok := r.typedEntry(normalizedKey, kind) if !ok { - return fmt.Errorf("legacy raw normalizer %q is not registered", normalizedKey) + return fmt.Errorf("normalizer %q variant for artifact kind %q is not registered", normalizedKey, kind) } - return validateRegisteredOptions(validator, options) + return validateRegisteredOptions(entry.validateOptions, options) } func (r *NormalizerRegistry) Spec(key string) (ModuleSpec, bool) { if r == nil { return ModuleSpec{}, false } - spec, ok := r.legacySpecs[strings.TrimSpace(key)] - if !ok { - return ModuleSpec{}, false + module := strings.TrimSpace(key) + for variant, entry := range r.typedEntries { + if variant.module == module { + return cloneModuleSpec(entry.spec), true + } } - return cloneModuleSpec(spec), true + return ModuleSpec{}, false } func (r *NormalizerRegistry) typedEntry(key string, kind contracts.ArtifactKind) (typedNormalizerEntry, bool) { @@ -229,10 +138,7 @@ func (r *NormalizerRegistry) RegisteredKeys() []string { if r == nil { return nil } - keys := make(map[string]struct{}, len(r.legacySpecs)+len(r.typedEntries)) - for key := range r.legacySpecs { - keys[key] = struct{}{} - } + keys := make(map[string]struct{}, len(r.typedEntries)) for key := range r.typedEntries { keys[key.module] = struct{}{} } diff --git a/internal/framework/pipeline/normalizer_registry_test.go b/internal/framework/pipeline/normalizer_registry_test.go deleted file mode 100644 index a4e7623..0000000 --- a/internal/framework/pipeline/normalizer_registry_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package pipeline - -import ( - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -func TestNormalizerRegistryBehavior(t *testing.T) { - runRegistryBehaviorTests(t, registryBehaviorCase[contracts.LegacyRawNormalizer]{ - name: "NormalizerRegistry", - key: "generic-normalizer", - stage: StageNormalize, - wrongStage: StageExtract, - newRegistry: func() any { - return NewNormalizerRegistry() - }, - register: func(registry any, key string, constructor func() (contracts.LegacyRawNormalizer, error)) error { - return registry.(*NormalizerRegistry).RegisterLegacyRaw(key, constructor) - }, - registerWithSpec: func(registry any, spec ModuleSpec, constructor func() (contracts.LegacyRawNormalizer, error)) error { - return registry.(*NormalizerRegistry).RegisterLegacyRawWithSpec(spec, constructor) - }, - build: func(registry any, key string) (contracts.LegacyRawNormalizer, error) { - return registry.(*NormalizerRegistry).BuildLegacyRaw(key) - }, - spec: func(registry any, key string) (ModuleSpec, bool) { - return registry.(*NormalizerRegistry).Spec(key) - }, - registeredKeys: func(registry any) []string { - return registry.(*NormalizerRegistry).RegisteredKeys() - }, - nilRegister: func(key string, constructor func() (contracts.LegacyRawNormalizer, error)) error { - var registry *NormalizerRegistry - return registry.RegisterLegacyRaw(key, constructor) - }, - nilBuild: func(key string) (contracts.LegacyRawNormalizer, error) { - var registry *NormalizerRegistry - return registry.BuildLegacyRaw(key) - }, - nilSpec: func(key string) (ModuleSpec, bool) { - var registry *NormalizerRegistry - return registry.Spec(key) - }, - nilRegisteredKey: func() []string { - var registry *NormalizerRegistry - return registry.RegisteredKeys() - }, - constructor: func(key string) func() (contracts.LegacyRawNormalizer, error) { - return func() (contracts.LegacyRawNormalizer, error) { - return registryNormalizer{key: key}, nil - } - }, - moduleKey: func(module contracts.LegacyRawNormalizer) string { - return module.Key() - }, - }) -} diff --git a/internal/framework/pipeline/output_registry.go b/internal/framework/pipeline/output_registry.go index a7ff3cd..5da88ee 100644 --- a/internal/framework/pipeline/output_registry.go +++ b/internal/framework/pipeline/output_registry.go @@ -32,7 +32,7 @@ func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor Ou if constructor == nil { return fmt.Errorf("output encoder constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return r.RegisterBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.OutputEncoder, error) { + return r.RegisterBuilderWithSpec(spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.OutputEncoder, error) { return constructor() }) } diff --git a/internal/framework/pipeline/preparation_test.go b/internal/framework/pipeline/preparation_test.go index 87ead8f..1369db7 100644 --- a/internal/framework/pipeline/preparation_test.go +++ b/internal/framework/pipeline/preparation_test.go @@ -1,11 +1,13 @@ package pipeline import ( + "context" "errors" "reflect" "strings" "testing" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) @@ -143,7 +145,7 @@ func constructionProfile() PipelineProfile { } } -func constructionRegistries(t *testing.T, built *[]string, failure *constructionFailure) (Registries, *runnerInputAdapter) { +func constructionRegistries(t *testing.T, built *[]string, failure *constructionFailure) (Registries, *constructionInput) { t.Helper() if built == nil { built = &[]string{} @@ -153,48 +155,64 @@ func constructionRegistries(t *testing.T, built *[]string, failure *construction } record := func(name string) { *built = append(*built, name) } strict := func(options map[string]any) error { return RejectUnknownOptions(options, "known") } - modules := defaultRunnerModules() + input := &constructionInput{key: "input"} registries := Registries{ Inputs: NewInputAdapterRegistry(), Chunkers: NewChunkerRegistry(), ArtifactCodecs: NewArtifactCodecRegistry(), Extractors: NewExtractorRegistry(), Mergers: NewMergerRegistry(), Normalizers: NewNormalizerRegistry(), Validators: NewValidatorRegistry(), ValidatorChains: NewValidatorChainRegistry(), Outputs: NewOutputEncoderRegistry(), } + if err := RegisterArtifactCodec(registries.ArtifactCodecs, notesCodec()); err != nil { + t.Fatal(err) + } if err := registries.Inputs.RegisterBuilderWithSpec(defaultModuleSpec("input", StageInput), strict, func(BuildRequest) (contracts.InputAdapter, error) { record("input") - return modules.input, nil + return input, nil }); err != nil { t.Fatal(err) } if err := registries.Chunkers.RegisterBuilderWithSpec(defaultModuleSpec("chunk", StageChunk), strict, func(BuildRequest) (contracts.Chunker, error) { record("chunk") - return modules.chunker, nil + return &typedTestChunker{key: "chunk"}, nil }); err != nil { t.Fatal(err) } - if err := registries.Extractors.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("extract", StageExtract), strict, func(request BuildRequest) (contracts.LegacyRawExtractor, error) { + extractSpec := defaultModuleSpec("extract", StageExtract) + extractSpec.ArtifactKind = "test/notes" + if err := RegisterExtractorBuilder(registries.Extractors, extractSpec, strict, func(request BuildRequest) (contracts.Extractor[codecNotes], error) { record("extract") if failure.requireExtractorLLM && request.Dependencies.LLM == nil { return nil, errors.New("structured LLM client is required") } - return &runnerExtractor{key: "extract"}, nil + return typedTestExtractor[codecNotes]{key: "extract"}, nil }); err != nil { t.Fatal(err) } - if err := registries.Mergers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("merge", StageMerge), strict, func(BuildRequest) (contracts.LegacyRawMerger, error) { + mergeSpec := defaultModuleSpec("merge", StageMerge) + mergeSpec.ArtifactKind = "test/notes" + if err := RegisterMergerBuilder(registries.Mergers, mergeSpec, strict, func(BuildRequest) (contracts.Merger[codecNotes], error) { record("merge") - return modules.mergers["merge"], nil + return typedTestMerger[codecNotes]{key: "merge"}, nil }); err != nil { t.Fatal(err) } - if err := registries.Normalizers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("normalize", StageNormalize), strict, func(BuildRequest) (contracts.LegacyRawNormalizer, error) { + normalizeSpec := defaultModuleSpec("normalize", StageNormalize) + normalizeSpec.ArtifactKind = "test/notes" + if err := RegisterNormalizerBuilder(registries.Normalizers, normalizeSpec, strict, func(BuildRequest) (contracts.Normalizer[codecNotes], error) { record("normalize") - return modules.normalizers["normalize"], nil + return typedTestNormalizer[codecNotes]{key: "normalize"}, nil }); err != nil { t.Fatal(err) } - if err := registries.Validators.RegisterLegacyRawBuilderWithSpec(ValidatorSpec{Key: "configured", ExecutionClass: contracts.ExecutionClassDeterministic}, strict, func(BuildRequest) (contracts.LegacyRawValidator, error) { + validatorSpec := ValidatorSpec{Key: "configured", ExecutionClass: contracts.ExecutionClassDeterministic} + if err := RegisterChunkValidatorBuilder(registries.Validators, validatorSpec, strict, func(BuildRequest) (contracts.ChunkValidator, error) { record("validator") - return modules.validators["configured"], nil + return typedTestChunkValidator{key: "configured"}, nil + }); err != nil { + t.Fatal(err) + } + if err := RegisterTypedValidatorBuilder(registries.Validators, "test/notes", validatorSpec, strict, func(BuildRequest) (contracts.TypedValidator[codecNotes], error) { + record("validator") + return typedTestValidator[codecNotes]{key: "configured"}, nil }); err != nil { t.Fatal(err) } @@ -203,9 +221,20 @@ func constructionRegistries(t *testing.T, built *[]string, failure *construction if failure.output != nil { return nil, failure.output } - return modules.output, nil + return &typedTestOutput{key: "output"}, nil }); err != nil { t.Fatal(err) } - return registries, modules.input + return registries, input +} + +type constructionInput struct { + key string + requests []contracts.ParseRequest +} + +func (input *constructionInput) Key() string { return input.key } +func (input *constructionInput) Parse(_ context.Context, request contracts.ParseRequest) (*source.SourceDocument, error) { + input.requests = append(input.requests, request) + return typedTestDocument(), nil } diff --git a/internal/framework/pipeline/prepare.go b/internal/framework/pipeline/prepare.go index 839789f..6f43e49 100644 --- a/internal/framework/pipeline/prepare.go +++ b/internal/framework/pipeline/prepare.go @@ -32,19 +32,12 @@ type PreparedArtifactLane struct { type preparedLaneExecutor struct { resolved ResolvedArtifactLane - legacy *preparedLegacyLane typed *preparedTypedLane extractValidators preparedValidatorChain mergeValidators preparedValidatorChain normalizeValidators preparedValidatorChain } -type preparedLegacyLane struct { - extractor contracts.LegacyRawExtractor - merger contracts.LegacyRawMerger - normalizer contracts.LegacyRawNormalizer -} - type preparedTypedLane struct { extractor any merger any @@ -62,7 +55,6 @@ type preparedValidatorChain struct { type preparedValidator struct { resolved ResolvedValidator - legacy contracts.LegacyRawValidator typed any typedValidate typedValidateOperation chunk contracts.ChunkValidator @@ -130,75 +122,50 @@ func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registrie request := func(binding ModuleBinding) BuildRequest { return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options)} } - if lane.ArtifactKind == "" { - extractor, err := registries.Extractors.BuildLegacyRawWithRequest(lane.Extract.Module, request(lane.Extract)) - if err != nil { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", err) - } - executor.legacy = &preparedLegacyLane{extractor: extractor} - } else { - entry, ok := registries.Extractors.typedEntry(lane.Extract.Module) - if !ok { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", fmt.Errorf("typed construction entry is not registered")) - } - module, err := buildErasedModule(entry.builder, request(lane.Extract), lane.Extract.Module, "extractor") - if err != nil { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", err) - } - codec, _, codecErr := registries.ArtifactCodecs.entry(lane.ArtifactKind) - if codecErr != nil { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", codecErr) - } - executor.typed = &preparedTypedLane{extractor: module, extract: entry.extract, codec: codec} + extractEntry, ok := registries.Extractors.typedEntry(lane.Extract.Module) + if !ok { + return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", fmt.Errorf("typed construction entry is not registered")) } + module, err := buildErasedModule(extractEntry.builder, request(lane.Extract), lane.Extract.Module, "extractor") + if err != nil { + return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", err) + } + codec, _, codecErr := registries.ArtifactCodecs.entry(lane.ArtifactKind) + if codecErr != nil { + return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageExtract, lane.Extract.Module, "", codecErr) + } + executor.typed = &preparedTypedLane{extractor: module, extract: extractEntry.extract, codec: codec} - var err error executor.extractValidators, err = prepareValidatorChain(pipeline, registries, deps, StageExtract, lane.ID, lane.Extract.Module) if err != nil { return preparedLaneExecutor{}, err } - if lane.ArtifactKind == "" { - module, err := registries.Mergers.BuildLegacyRawWithRequest(lane.Merge.Module, request(lane.Merge)) - if err != nil { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", err) - } - executor.legacy.merger = module - } else { - entry, ok := registries.Mergers.typedEntry(lane.Merge.Module, lane.ArtifactKind) - if !ok { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", fmt.Errorf("typed construction entry is not registered")) - } - module, err := buildErasedModule(entry.builder, request(lane.Merge), lane.Merge.Module, "merger") - if err != nil { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", err) - } - executor.typed.merger = module - executor.typed.merge = entry.merge + mergeEntry, ok := registries.Mergers.typedEntry(lane.Merge.Module, lane.ArtifactKind) + if !ok { + return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", fmt.Errorf("typed construction entry is not registered")) } + module, err = buildErasedModule(mergeEntry.builder, request(lane.Merge), lane.Merge.Module, "merger") + if err != nil { + return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageMerge, lane.Merge.Module, "", err) + } + executor.typed.merger = module + executor.typed.merge = mergeEntry.merge executor.mergeValidators, err = prepareValidatorChain(pipeline, registries, deps, StageMerge, lane.ID, lane.Merge.Module) if err != nil { return preparedLaneExecutor{}, err } - if lane.ArtifactKind == "" { - module, err := registries.Normalizers.BuildLegacyRawWithRequest(lane.Normalize.Module, request(lane.Normalize)) - if err != nil { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", err) - } - executor.legacy.normalizer = module - } else { - entry, ok := registries.Normalizers.typedEntry(lane.Normalize.Module, lane.ArtifactKind) - if !ok { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", fmt.Errorf("typed construction entry is not registered")) - } - module, err := buildErasedModule(entry.builder, request(lane.Normalize), lane.Normalize.Module, "normalizer") - if err != nil { - return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", err) - } - executor.typed.normalizer = module - executor.typed.normalize = entry.normalize + normalizeEntry, ok := registries.Normalizers.typedEntry(lane.Normalize.Module, lane.ArtifactKind) + if !ok { + return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", fmt.Errorf("typed construction entry is not registered")) } + module, err = buildErasedModule(normalizeEntry.builder, request(lane.Normalize), lane.Normalize.Module, "normalizer") + if err != nil { + return preparedLaneExecutor{}, constructionError(pipeline.ID, lane.ID, StageNormalize, lane.Normalize.Module, "", err) + } + executor.typed.normalizer = module + executor.typed.normalize = normalizeEntry.normalize executor.normalizeValidators, err = prepareValidatorChain(pipeline, registries, deps, StageNormalize, lane.ID, lane.Normalize.Module) if err != nil { return preparedLaneExecutor{}, err @@ -249,8 +216,7 @@ func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValida prepared.serialized, err = entry.builder(cloneBuildRequest(request)) implementation = prepared.serialized default: - prepared.legacy, err = registry.BuildLegacyRawWithRequest(key, request) - implementation = prepared.legacy + return preparedValidator{}, fmt.Errorf("validator construction target %q is not supported", resolved.Target) } if err != nil { return preparedValidator{}, err diff --git a/internal/framework/pipeline/profile.go b/internal/framework/pipeline/profile.go index af7079d..b431447 100644 --- a/internal/framework/pipeline/profile.go +++ b/internal/framework/pipeline/profile.go @@ -384,7 +384,7 @@ func configuredValidatorsError(pipelineID string, laneID string) error { func resolveArtifactIdentity(pipelineID, laneID string, lane *ResolvedArtifactLane, extractSpec ModuleSpec, catalog ModuleCatalog) (reflect.Type, error) { if extractSpec.ArtifactKind == "" { - return nil, nil + return nil, fmt.Errorf("pipeline %q lane %q extract module %q does not declare an artifact kind", pipelineID, laneID, lane.Extract.Module) } if catalog.Extractors == nil { return nil, fmt.Errorf("pipeline %q lane %q extractor registry must not be nil", pipelineID, laneID) @@ -393,9 +393,6 @@ func resolveArtifactIdentity(pipelineID, laneID string, lane *ResolvedArtifactLa if !ok { return nil, fmt.Errorf("pipeline %q lane %q extract module %q declares artifact kind %q without a typed registration", pipelineID, laneID, lane.Extract.Module, extractSpec.ArtifactKind) } - if catalog.Extractors.usesRawAdapter(lane.Extract.Module) { - return nil, nil - } if catalog.ArtifactCodecs == nil { return nil, fmt.Errorf("pipeline %q lane %q artifact codec registry must not be nil for kind %q", pipelineID, laneID, extractSpec.ArtifactKind) } @@ -470,7 +467,7 @@ func validatorSpecForTarget(registry *ValidatorRegistry, stage ModuleStage, key if spec, ok := registry.Spec(key); ok { return spec, "", nil } - return ValidatorSpec{}, "", fmt.Errorf("references unknown validator %q on legacy raw path", key) + return ValidatorSpec{}, "", fmt.Errorf("references unknown validator %q without an artifact kind", key) } if entry, ok := registry.typedEntry(key, kind); ok { if entry.valueType != expectedType { @@ -646,9 +643,6 @@ func validatePipelineReferenceDefaults( merge := resolveBinding(laneProfile.Merge, DefaultMergeModule) var artifactType reflect.Type artifactKind := extractSpec.ArtifactKind - if catalog.Extractors != nil && catalog.Extractors.usesRawAdapter(extract.Module) { - artifactKind = "" - } if artifactKind != "" && catalog.Extractors != nil { if entry, ok := catalog.Extractors.typedEntry(extract.Module); ok { artifactType = entry.valueType diff --git a/internal/framework/pipeline/profile_test.go b/internal/framework/pipeline/profile_test.go index 649b15b..7fc0572 100644 --- a/internal/framework/pipeline/profile_test.go +++ b/internal/framework/pipeline/profile_test.go @@ -766,20 +766,22 @@ func TestResolvePipelineUsesReferenceSlotsFromSpecWithoutConstructingExtractor(t profile := baselineProfile() profile.References = 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 := catalog.Extractors.RegisterLegacyRawWithSpec(ModuleSpec{ - Key: "event-extractor", - Stage: StageExtract, - Requires: []string{"chunk"}, - Provides: []string{"candidate"}, + if err := RegisterExtractor[codecNotes](catalog.Extractors, ModuleSpec{ + Key: "event-extractor", + Stage: StageExtract, + ArtifactKind: "test/notes", + Requires: []string{"chunk"}, + Provides: []string{"candidate"}, ReferenceSlots: []contracts.ReferenceSlot{ {Name: "roster", Required: true}, }, - }, func() (contracts.LegacyRawExtractor, error) { + }, func() (contracts.Extractor[codecNotes], error) { return nil, errors.New("constructor should not run") }); err != nil { t.Fatalf("RegisterWithSpec() error = %v, want nil", err) @@ -1177,6 +1179,7 @@ func newProfileCatalog(t *testing.T) ModuleCatalog { t.Helper() catalog := emptyProfileCatalog() + mustRegisterArtifactCodec(t, catalog.ArtifactCodecs, notesCodec()) registerProfileSpecs(t, catalog, defaultProfileSpecs()...) return catalog } @@ -1192,6 +1195,9 @@ func newProfileCatalogWithOverrides(t *testing.T, overrides ...ModuleSpec) Modul 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 { @@ -1206,6 +1212,7 @@ func newProfileCatalogWithOverrides(t *testing.T, overrides ...ModuleSpec) Modul } catalog := emptyProfileCatalog() + mustRegisterArtifactCodec(t, catalog.ArtifactCodecs, notesCodec()) registerProfileSpecs(t, catalog, specs...) return catalog } @@ -1228,10 +1235,10 @@ func defaultProfileSpecs() []ModuleSpec { return []ModuleSpec{ ModuleSpec{Key: "text", Stage: StageInput, Provides: []string{"source"}}, ModuleSpec{Key: "generic", Stage: StageChunk, Requires: []string{"source"}, Provides: []string{"chunk"}}, - ModuleSpec{Key: "event-extractor", Stage: StageExtract, Requires: []string{"chunk"}, Provides: []string{"candidate"}}, - ModuleSpec{Key: "note-extractor", Stage: StageExtract, Requires: []string{"chunk"}, Provides: []string{"candidate"}}, - ModuleSpec{Key: "appendorder", Stage: StageMerge, Requires: []string{"candidate"}, Provides: []string{"merged"}}, - ModuleSpec{Key: "noop", Stage: StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}}, + ModuleSpec{Key: "event-extractor", Stage: StageExtract, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}}, + ModuleSpec{Key: "note-extractor", Stage: StageExtract, ArtifactKind: "test/notes", Requires: []string{"chunk"}, Provides: []string{"candidate"}}, + ModuleSpec{Key: "appendorder", Stage: StageMerge, ArtifactKind: "test/notes", Requires: []string{"candidate"}, Provides: []string{"merged"}}, + ModuleSpec{Key: "noop", Stage: StageNormalize, ArtifactKind: "test/notes", Requires: []string{"merged"}, Provides: []string{"normalized"}}, ModuleSpec{Key: "grounded", Stage: StageValidate, Requires: []string{"normalized"}, Provides: []string{"validated"}}, ModuleSpec{Key: "json", Stage: StageOutput, Requires: []string{"normalized"}, Provides: []string{"encoded"}}, } @@ -1241,30 +1248,40 @@ func registerProfileSpecs(t *testing.T, catalog ModuleCatalog, specs ...ModuleSp 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: - if err := catalog.Chunkers.RegisterWithSpec(spec, profileChunkerConstructor(spec.Key)); err != nil { + 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 := catalog.Extractors.RegisterLegacyRawWithSpec(spec, profileExtractorConstructor(spec.Key)); err != nil { + 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 := catalog.Mergers.RegisterLegacyRawWithSpec(spec, profileMergerConstructor(spec.Key)); err != nil { + 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 := catalog.Normalizers.RegisterLegacyRawWithSpec(spec, profileNormalizerConstructor(spec.Key)); err != nil { + 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 := catalog.Validators.RegisterLegacyRawWithSpec(validatorSpec, profileValidatorConstructor(spec.Key)); err != nil { + 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: @@ -1279,7 +1296,9 @@ func registerProfileSpecs(t *testing.T, catalog ModuleCatalog, specs ...ModuleSp func registerProfileValidatorSpec(t *testing.T, catalog ModuleCatalog, spec ValidatorSpec) { t.Helper() - if err := catalog.Validators.RegisterLegacyRawWithSpec(spec, profileValidatorConstructor(spec.Key)); err != nil { + 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) } } @@ -1302,36 +1321,6 @@ func (adapter profileInputAdapter) Parse(ctx context.Context, req contracts.Pars return &source.SourceDocument{}, nil } -func profileChunkerConstructor(key string) ChunkerConstructor { - return func() (contracts.Chunker, error) { - return registryChunker{key: key}, nil - } -} - -func profileExtractorConstructor(key string) LegacyRawExtractorConstructor { - return func() (contracts.LegacyRawExtractor, error) { - return registryFakeExtractor{key: key}, nil - } -} - -func profileMergerConstructor(key string) LegacyRawMergerConstructor { - return func() (contracts.LegacyRawMerger, error) { - return registryMerger{key: key}, nil - } -} - -func profileNormalizerConstructor(key string) LegacyRawNormalizerConstructor { - return func() (contracts.LegacyRawNormalizer, error) { - return registryNormalizer{key: key}, nil - } -} - -func profileValidatorConstructor(key string) LegacyRawValidatorConstructor { - return func() (contracts.LegacyRawValidator, error) { - return registryValidator{name: key}, nil - } -} - func profileOutputConstructor(key string) OutputEncoderConstructor { return func() (contracts.OutputEncoder, error) { return registryOutputEncoder{key: key}, nil diff --git a/internal/framework/pipeline/reference_test_helpers_test.go b/internal/framework/pipeline/reference_test_helpers_test.go new file mode 100644 index 0000000..5121d59 --- /dev/null +++ b/internal/framework/pipeline/reference_test_helpers_test.go @@ -0,0 +1,14 @@ +package pipeline + +import "gitea.maximumdirect.net/eric/notarius/internal/core/source" + +func validSourceDocument() *source.SourceDocument { + return &source.SourceDocument{ + ID: "source-1", Kind: "document", Format: "text/plain", Digest: "sha256:source", + Units: []source.SourceUnit{ + {ID: 1, Kind: "unit", Text: "Source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}}, + {ID: 2, Kind: "unit", Text: "Second source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 2, EndUnitID: 2}}, + {ID: 3, Kind: "unit", Text: "Third source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 3, EndUnitID: 3}}, + }, + } +} diff --git a/internal/framework/pipeline/registry_integration_test.go b/internal/framework/pipeline/registry_integration_test.go deleted file mode 100644 index 1e50954..0000000 --- a/internal/framework/pipeline/registry_integration_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package pipeline - -import ( - "context" - "reflect" - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/core/source" - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -func TestRunnerUsesRegistries(t *testing.T) { - var built []string - var executed []string - registries := integrationRegistries(t, &built, &executed) - - output, err := newPreparedRunner(t, registries).Run(context.Background(), RunInput{ - pipeline: integrationPipeline(), - SourceID: "source-1", - RawInput: []byte("source text"), - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - wantBuilt := []string{"input", "chunk", "extract-first", "merge", "normalize", "extract-second", "merge", "normalize", "output"} - if !reflect.DeepEqual(built, wantBuilt) { - t.Fatalf("built = %#v, want %#v", built, wantBuilt) - } - if !reflect.DeepEqual(executed, []string{"extract-first:chunk-0", "extract-second:chunk-0"}) { - t.Fatalf("executed = %#v, want extractor chunk execution", executed) - } - if got := normalizeOutputKeys(output.NormalizeOutputs); !reflect.DeepEqual(got, []string{"normalize", "normalize"}) { - t.Fatalf("normalize output keys = %#v, want one output from each lane", got) - } - if len(output.Rejected) != 0 { - t.Fatalf("len(Rejected) = %d, want none", len(output.Rejected)) - } -} - -func integrationRegistries(t *testing.T, built, executed *[]string) Registries { - t.Helper() - - registries := Registries{ - Inputs: NewInputAdapterRegistry(), - Chunkers: NewChunkerRegistry(), - ArtifactCodecs: NewArtifactCodecRegistry(), - Extractors: NewExtractorRegistry(), - Mergers: NewMergerRegistry(), - Normalizers: NewNormalizerRegistry(), - Outputs: NewOutputEncoderRegistry(), - } - if err := registries.Inputs.Register("input", func() (contracts.InputAdapter, error) { - *built = append(*built, "input") - return integrationInput{}, nil - }); err != nil { - t.Fatalf("register input: %v", err) - } - if err := registries.Chunkers.Register("chunk", func() (contracts.Chunker, error) { - *built = append(*built, "chunk") - return integrationChunker{}, nil - }); err != nil { - t.Fatalf("register chunker: %v", err) - } - registerIntegrationExtractor(t, registries.Extractors, "extract-first", built, executed) - registerIntegrationExtractor(t, registries.Extractors, "extract-second", built, executed) - if err := registries.Mergers.RegisterLegacyRaw("merge", func() (contracts.LegacyRawMerger, error) { - *built = append(*built, "merge") - return integrationMerger{}, nil - }); err != nil { - t.Fatalf("register merger: %v", err) - } - if err := registries.Normalizers.RegisterLegacyRaw("normalize", func() (contracts.LegacyRawNormalizer, error) { - *built = append(*built, "normalize") - return integrationNormalizer{}, nil - }); err != nil { - t.Fatalf("register normalizer: %v", err) - } - if err := registries.Outputs.Register("output", func() (contracts.OutputEncoder, error) { - *built = append(*built, "output") - return integrationOutput{}, nil - }); err != nil { - t.Fatalf("register output: %v", err) - } - return registries -} - -func registerIntegrationExtractor(t *testing.T, registry *ExtractorRegistry, key string, built, executed *[]string) { - t.Helper() - - if err := registry.RegisterLegacyRaw(key, func() (contracts.LegacyRawExtractor, error) { - *built = append(*built, key) - return integrationExtractor{key: key, executed: executed}, nil - }); err != nil { - t.Fatalf("Register(%q) error = %v, want nil", key, err) - } -} - -type integrationInput struct{} - -func (input integrationInput) Key() string { - return "input" -} - -func (input integrationInput) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { - return integrationSourceDocument(), nil -} - -type integrationChunker struct{} - -func (chunker integrationChunker) Key() string { - return "chunk" -} - -func (chunker integrationChunker) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (chunker integrationChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) { - return contracts.ChunkResult{ - Chunks: []source.Chunk{ - { - ID: "chunk-0", - SourceID: req.Source.ID, - Index: 0, - Ref: source.SourceRef{ - SourceID: req.Source.ID, - StartUnitID: req.Source.Units[0].ID, - EndUnitID: req.Source.Units[len(req.Source.Units)-1].ID, - }, - Content: []byte(`{"units":[1]}`), - MediaType: "application/json", - Units: req.Source.Units, - }, - }, - }, nil -} - -type integrationExtractor struct { - key string - executed *[]string -} - -func (extractor integrationExtractor) Key() string { - return extractor.key -} - -func (extractor integrationExtractor) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (extractor integrationExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - *extractor.executed = append(*extractor.executed, extractor.key+":"+req.Chunk.ID) - return contracts.ExtractionResult{ - Output: contracts.ExtractOutput{ - Schema: contracts.ResponseSchema{ID: "integration", Name: "integration", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(`{"value":true}`), - MediaType: "application/json", - }, - }, - }, nil -} - -type integrationNormalizer struct{} - -type integrationMerger struct{} - -func (merger integrationMerger) Key() string { - return "merge" -} - -func (merger integrationMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { - output := contracts.MergeOutput{ - LaneID: req.LaneID, - Schema: contracts.ResponseSchema{ID: "integration", Name: "integration", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(`{"merged":true}`), - MediaType: "application/json", - }, - } - if len(req.ExtractOutputs) > 0 { - output.SourceID = req.ExtractOutputs[0].SourceID - output.Schema = req.ExtractOutputs[0].Schema - output.Payload = req.ExtractOutputs[0].Payload - } - return contracts.MergeResult{Output: output}, nil -} - -func (normalizer integrationNormalizer) Key() string { - return "normalize" -} - -func (normalizer integrationNormalizer) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (normalizer integrationNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { - return contracts.NormalizeResult{ - Output: contracts.NormalizeOutput{ - LaneID: req.LaneID, - SourceID: req.MergeOutput.SourceID, - Schema: req.MergeOutput.Schema, - Payload: req.MergeOutput.Payload, - }, - }, nil -} - -type integrationOutput struct{} - -func (output integrationOutput) Key() string { - return "output" -} - -func (output integrationOutput) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { - return contracts.OutputResult{ - Files: []contracts.OutputFile{ - {Name: "output.json", ContentType: "application/json", Bytes: []byte(`{}`)}, - }, - }, nil -} - -func integrationPipeline() ResolvedPipeline { - return ResolvedPipeline{ - ID: "pipeline-1", - Digest: "sha256:pipeline", - Input: Binding("input"), - Chunk: Binding("chunk"), - ArtifactLanes: []ResolvedArtifactLane{ - { - ID: "first", - Extract: Binding("extract-first"), - Merge: Binding("merge"), - Normalize: Binding("normalize"), - }, - { - ID: "second", - Extract: Binding("extract-second"), - Merge: Binding("merge"), - Normalize: Binding("normalize"), - }, - }, - Output: Binding("output"), - } -} - -func integrationSourceDocument() *source.SourceDocument { - return &source.SourceDocument{ - ID: "source-1", - Kind: "document", - Format: "text/plain", - Digest: "sha256:abc123", - Units: []source.SourceUnit{ - {ID: 1, Kind: "unit", Text: "Source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}}, - }, - } -} - -func normalizeOutputKeys(outputs []contracts.SerializedOutput) []string { - keys := make([]string, 0, len(outputs)) - for _, output := range outputs { - keys = append(keys, output.NormalizerKey) - } - return keys -} diff --git a/internal/framework/pipeline/runner.go b/internal/framework/pipeline/runner.go index 3327c11..da9fc0f 100644 --- a/internal/framework/pipeline/runner.go +++ b/internal/framework/pipeline/runner.go @@ -245,7 +245,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err }, llmScope)) return false, nil, err } - validationWarnings, rejection, err := r.validateChunks(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.llmClient, input.Metadata, input.Prepared.chunkValidators, attempt, input.Debug) + validationWarnings, rejection, err := r.validateChunks(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.Metadata, input.Prepared.chunkValidators, attempt, input.Debug) if err != nil || rejection != nil { _ = writeDebugTimed(debugRecorder, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ Stage: string(StageChunk), @@ -323,7 +323,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err } else { output.Manifest.ValidationStatus = "approved" } - populateRawOutputManifest(&output) + populateOutputManifest(&output) output.Manifest.CompletedAt = timePtr(time.Now().UTC()) encoder := input.Prepared.output @@ -377,534 +377,16 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err } func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints CheckpointRecorder, checkpointLoader CheckpointLoader, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []source.Chunk, prepared preparedLaneExecutor, output *RunOutput) error { - if prepared.typed != nil { - return r.runTypedLane(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, chunks, prepared, output) - } - return r.runLegacyLane(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, chunks, prepared, output) -} - -func (r *Runner) runLegacyLane(ctx context.Context, input RunInput, checkpoints CheckpointRecorder, checkpointLoader CheckpointLoader, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []source.Chunk, prepared preparedLaneExecutor, output *RunOutput) error { - lane := prepared.resolved - if prepared.legacy == nil { - return fmt.Errorf("resolved pipeline lane %q uses typed artifact kind %q, which the legacy raw runner cannot execute", lane.ID, lane.ArtifactKind) - } - extractor := prepared.legacy.extractor - merger := prepared.legacy.merger - normalizer := prepared.legacy.normalizer - setLaneManifestMetadata(output, lane.ID, extractor, merger, normalizer) - - extractOutputs := make([]contracts.ExtractOutput, 0, len(chunks)) - extractWarnings := []contracts.Warning{} - extractRejectedStart := len(output.Rejected) - chunksDigest, err := joinedChunkDigest(chunks) - if err != nil { - return fmt.Errorf("digest chunks for lane %q: %w", lane.ID, err) - } - extractDependencies := digestFingerprints("chunks", chunksDigest) - extractCheckpoint, extractDecision := checkpointLoader.Extract(lane.ID, extractor.Key(), extractDependencies) - recordCheckpointEvent(output, checkpointLoader, string(StageExtract), lane.ID, extractor.Key(), extractDecision) - extractStarted := time.Now().UTC() - if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{ - Stage: string(StageExtract), - LaneID: lane.ID, - ModuleKey: extractor.Key(), - StartedAt: extractStarted, - Payload: map[string]any{ - "reused": extractDecision.Reused, - "decision": extractDecision, - "source": debugSourceDocumentEnvelope(doc), - "chunks": debugSourceChunkEnvelopes(chunks), - "options": redactSensitiveMap(lane.Extract.Options), - "metadata": redactSensitiveMap(input.Metadata), - }, - }); err != nil { - return fmt.Errorf("write extract debug artifact for lane %q: %w", lane.ID, err) - } - if extractDecision.Reused { - extractOutputs = cloneExtractOutputs(extractCheckpoint.Outputs) - extractWarnings = cloneWarnings(extractCheckpoint.Warnings) - output.Rejected = append(output.Rejected, cloneRejectedOutputs(extractCheckpoint.Rejected)...) - output.Warnings = append(output.Warnings, extractWarnings...) - } else { - if err := checkpoints.ExtractRunning(lane.ID, extractor.Key(), extractDependencies); err != nil { - return fmt.Errorf("write extract checkpoint for lane %q: %w", lane.ID, err) - } - for index := range chunks { - chunk := chunks[index] - var acceptedOutput contracts.ExtractOutput - var acceptedWarnings []contracts.Warning - accepted, rejection, err := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) { - attemptStarted := time.Now().UTC() - attemptPath := path.Join("extract", debugPathComponent(lane.ID), fmt.Sprintf("chunk-%06d", chunk.Index+1), fmt.Sprintf("attempt-%02d", attempt)) - attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath) - result, err := extractor.Extract(attemptCtx, contracts.ExtractionRequest{ - Source: doc, - Chunk: &chunk, - SourceInput: chunkInputMaterial(sourceInput, chunk), - SessionID: sessionID, - References: CloneReferenceSet(lane.ExtractReferences.ReferenceSet), - LLMClient: input.llmClient, - LLMProfile: lane.Extract.LLMProfile, - Options: cloneOptions(lane.Extract.Options), - Metadata: input.Metadata, - }) - if err != nil { - _ = writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageExtract), - LaneID: lane.ID, - ModuleKey: extractor.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Error: err.Error(), - }, llmScope)) - return false, nil, fmt.Errorf("extract lane %q chunk %q with extractor %q: %w", lane.ID, chunk.ID, extractor.Key(), err) - } - extractOutput := result.Output - extractOutput.LaneID = lane.ID - extractOutput.ExtractorKey = extractor.Key() - extractOutput.SourceID = doc.ID - extractOutput.ChunkID = chunk.ID - extractOutput.ChunkIndex = chunk.Index - extractOutput.Payload.Warnings = append(extractOutput.Payload.Warnings, result.Warnings...) - validationWarnings, rejection, err := r.validateRaw(attemptCtx, rawValidationTarget{ - stage: StageExtract, - laneID: lane.ID, - moduleKey: extractor.Key(), - source: doc, - sourceID: doc.ID, - chunkID: chunk.ID, - chunkIndex: chunk.Index, - chunk: &chunk, - sourceInput: chunkInputMaterial(sourceInput, chunk), - sessionID: sessionID, - references: lane.ExtractReferences.ReferenceSet, - llmClient: input.llmClient, - schema: extractOutput.Schema, - payload: extractOutput.Payload, - metadata: input.Metadata, - prepared: prepared.extractValidators, - attempt: attempt, - debug: input.Debug, - }) - if err != nil || rejection != nil { - _ = writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageExtract), - LaneID: lane.ID, - ModuleKey: extractor.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Payload: map[string]any{ - "output": debugExtractOutputEnvelope(extractOutput), - "warnings": append(cloneWarnings(result.Warnings), validationWarnings...), - "rejection": debugRejectedOutputPtr(rejection), - }, - }, llmScope)) - return false, rejection, err - } - acceptedOutput = cloneExtractOutput(extractOutput) - acceptedWarnings = append(cloneWarnings(result.Warnings), validationWarnings...) - if err := writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageExtract), - LaneID: lane.ID, - ModuleKey: extractor.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Payload: map[string]any{ - "output": debugExtractOutputEnvelope(extractOutput), - "warnings": acceptedWarnings, - }, - }, llmScope)); err != nil { - return false, nil, err - } - return true, nil, nil - }) - if err != nil { - _ = checkpoints.ExtractFailed(lane.ID, extractor.Key(), extractDependencies, err) - return err - } - if !accepted { - output.Rejected = append(output.Rejected, *rejection) - continue - } - output.Warnings = append(output.Warnings, acceptedWarnings...) - extractWarnings = append(extractWarnings, acceptedWarnings...) - extractOutputs = append(extractOutputs, acceptedOutput) - } - extractRejected := cloneRejectedOutputs(output.Rejected[extractRejectedStart:]) - if err := checkpoints.ExtractSucceeded(lane.ID, extractor.Key(), extractDependencies, extractOutputs, extractRejected, extractWarnings); err != nil { - return fmt.Errorf("write extract checkpoint for lane %q: %w", lane.ID, err) - } - } - if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{ - Stage: string(StageExtract), - LaneID: lane.ID, - ModuleKey: extractor.Key(), - StartedAt: extractStarted, - Payload: map[string]any{ - "reused": extractDecision.Reused, - "outputs": debugExtractOutputEnvelopes(extractOutputs), - "rejected": debugRejectedOutputEnvelopes(output.Rejected[extractRejectedStart:]), - "warnings": extractWarnings, - }, - }); err != nil { - return fmt.Errorf("write extract debug artifact for lane %q: %w", lane.ID, err) - } - - if len(extractOutputs) == 0 { - return nil - } - - var acceptedMerge contracts.MergeOutput - var mergeWarnings []contracts.Warning - mergeDependencies := rawOutputDigests(extractPayloads(extractOutputs)) - mergeCheckpoint, mergeDecision := checkpointLoader.Merge(lane.ID, merger.Key(), mergeDependencies) - recordCheckpointEvent(output, checkpointLoader, string(StageMerge), lane.ID, merger.Key(), mergeDecision) - mergeStarted := time.Now().UTC() - if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{ - Stage: string(StageMerge), - LaneID: lane.ID, - ModuleKey: merger.Key(), - StartedAt: mergeStarted, - Payload: map[string]any{ - "reused": mergeDecision.Reused, - "decision": mergeDecision, - "source": debugSourceDocumentEnvelope(doc), - "extract_outputs": debugExtractOutputEnvelopes(extractOutputs), - "options": redactSensitiveMap(lane.Merge.Options), - "metadata": redactSensitiveMap(input.Metadata), - }, - }); err != nil { - return fmt.Errorf("write merge debug artifact for lane %q: %w", lane.ID, err) - } - if mergeDecision.Reused { - acceptedMerge = cloneMergeOutput(mergeCheckpoint.Output) - mergeWarnings = cloneWarnings(mergeCheckpoint.Warnings) - output.Warnings = append(output.Warnings, mergeWarnings...) - } else { - if err := checkpoints.MergeRunning(lane.ID, merger.Key(), mergeDependencies); err != nil { - return fmt.Errorf("write merge checkpoint for lane %q: %w", lane.ID, err) - } - mergeAccepted, mergeRejection, err := runWithRetry(ctx, lane.Merge.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) { - attemptStarted := time.Now().UTC() - attemptPath := path.Join("merge", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt)) - attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath) - mergeResult, err := merger.Merge(attemptCtx, contracts.MergeRequest{ - Source: doc, - LaneID: lane.ID, - ExtractOutputs: cloneExtractOutputs(extractOutputs), - SourceInput: sourceInput.Clone(), - SessionID: sessionID, - References: CloneReferenceSet(lane.MergeReferences.ReferenceSet), - LLMClient: input.llmClient, - LLMProfile: lane.Merge.LLMProfile, - Options: cloneOptions(lane.Merge.Options), - Metadata: input.Metadata, - }) - if err != nil { - _ = writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageMerge), - LaneID: lane.ID, - ModuleKey: merger.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Error: err.Error(), - }, llmScope)) - return false, nil, fmt.Errorf("merge lane %q with merger %q: %w", lane.ID, merger.Key(), err) - } - mergeOutput := mergeResult.Output - mergeOutput.LaneID = lane.ID - mergeOutput.MergerKey = merger.Key() - mergeOutput.SourceID = doc.ID - mergeOutput.Payload.Warnings = append(mergeOutput.Payload.Warnings, mergeResult.Warnings...) - validationWarnings, rejection, err := r.validateRaw(attemptCtx, rawValidationTarget{ - stage: StageMerge, - laneID: lane.ID, - moduleKey: merger.Key(), - source: doc, - sourceID: doc.ID, - sourceInput: sourceInput.Clone(), - sessionID: sessionID, - references: lane.MergeReferences.ReferenceSet, - llmClient: input.llmClient, - schema: mergeOutput.Schema, - payload: mergeOutput.Payload, - extractOutputs: extractOutputs, - metadata: input.Metadata, - prepared: prepared.mergeValidators, - attempt: attempt, - debug: input.Debug, - }) - if err != nil || rejection != nil { - _ = writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageMerge), - LaneID: lane.ID, - ModuleKey: merger.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Payload: map[string]any{ - "output": debugMergeOutputEnvelope(mergeOutput), - "warnings": append(cloneWarnings(mergeResult.Warnings), validationWarnings...), - "rejection": debugRejectedOutputPtr(rejection), - }, - }, llmScope)) - return false, rejection, err - } - acceptedMerge = cloneMergeOutput(mergeOutput) - mergeWarnings = append(cloneWarnings(mergeResult.Warnings), validationWarnings...) - if err := writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageMerge), - LaneID: lane.ID, - ModuleKey: merger.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Payload: map[string]any{ - "output": debugMergeOutputEnvelope(mergeOutput), - "warnings": mergeWarnings, - }, - }, llmScope)); err != nil { - return false, nil, err - } - return true, nil, nil - }) - if err != nil { - _ = checkpoints.MergeFailed(lane.ID, merger.Key(), mergeDependencies, err) - return err - } - if !mergeAccepted { - output.Rejected = append(output.Rejected, *mergeRejection) - if err := checkpoints.MergeRejected(lane.ID, merger.Key(), mergeDependencies, *mergeRejection); err != nil { - return fmt.Errorf("write merge checkpoint for lane %q: %w", lane.ID, err) - } - if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{ - Stage: string(StageMerge), - LaneID: lane.ID, - ModuleKey: merger.Key(), - StartedAt: mergeStarted, - Payload: map[string]any{ - "accepted": false, - "rejection": debugRejectedOutputEnvelope(*mergeRejection), - "warnings": mergeWarnings, - }, - }); err != nil { - return fmt.Errorf("write merge debug artifact for lane %q: %w", lane.ID, err) - } - return nil - } - output.Warnings = append(output.Warnings, mergeWarnings...) - if err := checkpoints.MergeSucceeded(lane.ID, merger.Key(), mergeDependencies, acceptedMerge, mergeWarnings); err != nil { - return fmt.Errorf("write merge checkpoint for lane %q: %w", lane.ID, err) - } - } - if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{ - Stage: string(StageMerge), - LaneID: lane.ID, - ModuleKey: merger.Key(), - StartedAt: mergeStarted, - Payload: map[string]any{ - "reused": mergeDecision.Reused, - "accepted": true, - "output": debugMergeOutputEnvelope(acceptedMerge), - "warnings": mergeWarnings, - }, - }); err != nil { - return fmt.Errorf("write merge debug artifact for lane %q: %w", lane.ID, err) - } - - var acceptedNormalize contracts.NormalizeOutput - var normalizeWarnings []contracts.Warning - normalizeDependencies := rawOutputDigests([]contracts.RawPayload{acceptedMerge.Payload}) - normalizeCheckpoint, normalizeDecision := checkpointLoader.Normalize(lane.ID, normalizer.Key(), normalizeDependencies) - recordCheckpointEvent(output, checkpointLoader, string(StageNormalize), lane.ID, normalizer.Key(), normalizeDecision) - normalizeStarted := time.Now().UTC() - if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{ - Stage: string(StageNormalize), - LaneID: lane.ID, - ModuleKey: normalizer.Key(), - StartedAt: normalizeStarted, - Payload: map[string]any{ - "reused": normalizeDecision.Reused, - "decision": normalizeDecision, - "source": debugSourceDocumentEnvelope(doc), - "merge_output": debugMergeOutputEnvelope(acceptedMerge), - "options": redactSensitiveMap(lane.Normalize.Options), - "metadata": redactSensitiveMap(input.Metadata), - }, - }); err != nil { - return fmt.Errorf("write normalize debug artifact for lane %q: %w", lane.ID, err) - } - if normalizeDecision.Reused { - acceptedNormalize = cloneNormalizeOutput(normalizeCheckpoint.Output) - normalizeWarnings = cloneWarnings(normalizeCheckpoint.Warnings) - output.Warnings = append(output.Warnings, normalizeWarnings...) - } else { - if err := checkpoints.NormalizeRunning(lane.ID, normalizer.Key(), normalizeDependencies); err != nil { - return fmt.Errorf("write normalize checkpoint for lane %q: %w", lane.ID, err) - } - normalizeAccepted, normalizeRejection, err := runWithRetry(ctx, lane.Normalize.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) { - attemptStarted := time.Now().UTC() - attemptPath := path.Join("normalize", debugPathComponent(lane.ID), fmt.Sprintf("attempt-%02d", attempt)) - attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath) - normalizeResult, err := normalizer.Normalize(attemptCtx, contracts.NormalizeRequest{ - Source: doc, - LaneID: lane.ID, - MergeOutput: cloneMergeOutput(acceptedMerge), - SourceInput: sourceInput.Clone(), - SessionID: sessionID, - References: CloneReferenceSet(lane.NormalizeReferences.ReferenceSet), - LLMClient: input.llmClient, - LLMProfile: lane.Normalize.LLMProfile, - Options: cloneOptions(lane.Normalize.Options), - Metadata: input.Metadata, - }) - if err != nil { - _ = writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageNormalize), - LaneID: lane.ID, - ModuleKey: normalizer.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Error: err.Error(), - }, llmScope)) - return false, nil, fmt.Errorf("normalize lane %q with normalizer %q: %w", lane.ID, normalizer.Key(), err) - } - normalizeOutput := normalizeResult.Output - normalizeOutput.LaneID = lane.ID - normalizeOutput.NormalizerKey = normalizer.Key() - normalizeOutput.SourceID = doc.ID - normalizeOutput.Payload.Warnings = append(normalizeOutput.Payload.Warnings, normalizeResult.Warnings...) - validationWarnings, rejection, err := r.validateRaw(attemptCtx, rawValidationTarget{ - stage: StageNormalize, - laneID: lane.ID, - moduleKey: normalizer.Key(), - source: doc, - sourceID: doc.ID, - sourceInput: sourceInput.Clone(), - sessionID: sessionID, - references: lane.NormalizeReferences.ReferenceSet, - llmClient: input.llmClient, - schema: normalizeOutput.Schema, - payload: normalizeOutput.Payload, - mergeOutput: acceptedMerge, - metadata: input.Metadata, - prepared: prepared.normalizeValidators, - attempt: attempt, - debug: input.Debug, - }) - if err != nil || rejection != nil { - _ = writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageNormalize), - LaneID: lane.ID, - ModuleKey: normalizer.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Payload: map[string]any{ - "output": debugNormalizeOutputEnvelope(normalizeOutput), - "warnings": append(cloneWarnings(normalizeResult.Warnings), validationWarnings...), - "rejection": debugRejectedOutputPtr(rejection), - }, - }, llmScope)) - return false, rejection, err - } - acceptedNormalize = cloneNormalizeOutput(normalizeOutput) - normalizeWarnings = append(cloneWarnings(normalizeResult.Warnings), validationWarnings...) - if err := writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(StageNormalize), - LaneID: lane.ID, - ModuleKey: normalizer.Key(), - Attempt: attempt, - StartedAt: attemptStarted, - Payload: map[string]any{ - "output": debugNormalizeOutputEnvelope(normalizeOutput), - "warnings": normalizeWarnings, - }, - }, llmScope)); err != nil { - return false, nil, err - } - return true, nil, nil - }) - if err != nil { - _ = checkpoints.NormalizeFailed(lane.ID, normalizer.Key(), normalizeDependencies, err) - return err - } - if !normalizeAccepted { - output.Rejected = append(output.Rejected, *normalizeRejection) - if err := checkpoints.NormalizeRejected(lane.ID, normalizer.Key(), normalizeDependencies, *normalizeRejection); err != nil { - return fmt.Errorf("write normalize checkpoint for lane %q: %w", lane.ID, err) - } - if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{ - Stage: string(StageNormalize), - LaneID: lane.ID, - ModuleKey: normalizer.Key(), - StartedAt: normalizeStarted, - Payload: map[string]any{ - "accepted": false, - "rejection": debugRejectedOutputEnvelope(*normalizeRejection), - "warnings": normalizeWarnings, - }, - }); err != nil { - return fmt.Errorf("write normalize debug artifact for lane %q: %w", lane.ID, err) - } - return nil - } - output.Warnings = append(output.Warnings, normalizeWarnings...) - if err := checkpoints.NormalizeSucceeded(lane.ID, normalizer.Key(), normalizeDependencies, acceptedNormalize, normalizeWarnings); err != nil { - return fmt.Errorf("write normalize checkpoint for lane %q: %w", lane.ID, err) - } - } - if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{ - Stage: string(StageNormalize), - LaneID: lane.ID, - ModuleKey: normalizer.Key(), - StartedAt: normalizeStarted, - Payload: map[string]any{ - "reused": normalizeDecision.Reused, - "accepted": true, - "output": debugNormalizeOutputEnvelope(acceptedNormalize), - "warnings": normalizeWarnings, - }, - }); err != nil { - return fmt.Errorf("write normalize debug artifact for lane %q: %w", lane.ID, err) - } - output.NormalizeOutputs = append(output.NormalizeOutputs, serializedOutputFromLegacy(acceptedNormalize)) - return nil -} - -type rawValidationTarget struct { - stage ModuleStage - laneID string - moduleKey string - source *source.SourceDocument - sourceID string - sourceInput contracts.LLMInputMaterial - sessionID string - references contracts.ReferenceSet - llmClient contracts.StructuredLLMClient - chunkID string - chunkIndex int - chunk *source.Chunk - chunks []source.Chunk - schema contracts.ResponseSchema - payload contracts.RawPayload - extractOutputs []contracts.ExtractOutput - mergeOutput contracts.MergeOutput - metadata map[string]any - prepared preparedValidatorChain - attempt int - debug DebugRecorder + return r.runTypedLane(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, chunks, prepared, output) } func runWithRetry(ctx context.Context, retries int, run func(attempt int) (bool, *contracts.RejectedOutput, error)) (bool, *contracts.RejectedOutput, error) { - attempts := 1 - if retries > 0 { - attempts += retries - } - - var lastRejection *contracts.RejectedOutput + attempts := retries + 1 + var last *contracts.RejectedOutput for attempt := 1; attempt <= attempts; attempt++ { if err := ctx.Err(); err != nil { return false, nil, err } - accepted, rejection, err := run(attempt) if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { @@ -920,55 +402,22 @@ func runWithRetry(ctx context.Context, retries int, run func(attempt int) (bool, } if rejection != nil { rejection.AttemptCount = attempt - lastRejection = rejection + last = rejection } if ctxErr := ctx.Err(); ctxErr != nil { return false, nil, ctxErr } if attempt == attempts { - if lastRejection == nil { - lastRejection = &contracts.RejectedOutput{ - ReasonCode: "raw_output_rejected", - Message: "raw output rejected", - AttemptCount: attempt, - } + if last == nil { + last = &contracts.RejectedOutput{ReasonCode: "output_rejected", Message: "output rejected", AttemptCount: attempt} } - return false, lastRejection, nil + return false, last, nil } } - - return false, lastRejection, nil + return false, last, nil } -func (r *Runner) validateChunksRaw(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, llmClient contracts.StructuredLLMClient, metadata map[string]any, prepared preparedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) { - return r.validateRaw(ctx, rawValidationTarget{ - stage: StageChunk, - moduleKey: moduleKey, - source: doc, - sourceID: doc.ID, - sourceInput: sourceInput.Clone(), - sessionID: sessionID, - references: references, - llmClient: llmClient, - chunks: chunks, - metadata: metadata, - prepared: prepared, - attempt: attempt, - debug: debug, - }) -} - -func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, llmClient contracts.StructuredLLMClient, metadata map[string]any, prepared preparedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) { - allLegacy := true - for _, item := range prepared.validators { - if item.resolved.Target != ValidatorTargetLegacyRaw && item.resolved.Target != "" { - allLegacy = false - break - } - } - if allLegacy { - return r.validateChunksRaw(ctx, doc, moduleKey, chunks, sourceInput, sessionID, references, llmClient, metadata, prepared, attempt, debug) - } +func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, moduleKey string, chunks []source.Chunk, sourceInput contracts.LLMInputMaterial, sessionID string, references contracts.ReferenceSet, metadata map[string]any, prepared preparedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) { content, err := json.Marshal(chunks) if err != nil { return nil, nil, fmt.Errorf("encode canonical chunks for validation: %w", err) @@ -989,8 +438,9 @@ func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, default: return nil, nil, fmt.Errorf("validator %q is incompatible with chunk validation", binding.Module) } - debugRequest := contracts.ValidationRequest{Stage: string(StageChunk), ModuleKey: moduleKey, Source: doc, SourceID: doc.ID, SourceInput: sourceInput.Clone(), SessionID: sessionID, References: CloneReferenceSet(references), LLMProfile: binding.LLMProfile, Metadata: cloneMetadata(metadata), Chunks: cloneSourceChunks(chunks), Schema: contracts.ResponseSchema{ID: schema.ID, Name: schema.Name, Version: schema.Version, JSONSchema: append([]byte(nil), schema.JSONSchema...)}, Payload: contracts.RawPayload{Content: content, MediaType: "application/json"}} - debugCall := debugValidationCall{ValidatorName: binding.Module, Request: debugValidationRequestEnvelope(debugRequest), Result: debugValidationResultEnvelope(result)} + debugContent := debugContentEnvelope(content, "application/json", nil, nil) + debugContent.ContentDigest = debugContentDigest(content) + debugCall := debugValidationCall{ValidatorName: binding.Module, Request: map[string]any{"stage": string(StageChunk), "module_key": moduleKey, "source_id": doc.ID, "schema": schema, "schema_digest": contracts.DigestArtifactSchema(schema), "content": debugContent, "metadata": redactSensitiveMap(metadata)}, Result: debugValidationResultEnvelope(result)} if err != nil { debugCall.Error = err.Error() } @@ -1003,11 +453,11 @@ func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, if !result.Approved { reason := result.ReasonCode if reason == "" { - reason = "raw_output_rejected" + reason = "output_rejected" } message := result.Message if message == "" { - message = "raw output rejected" + message = "output rejected" } return nil, &contracts.RejectedOutput{Stage: string(StageChunk), ModuleKey: moduleKey, ValidatorName: binding.Module, ReasonCode: reason, Message: message, AttemptCount: attempt, DiagnosticArtifactPath: result.DiagnosticArtifactPath}, nil } @@ -1016,97 +466,6 @@ func (r *Runner) validateChunks(ctx context.Context, doc *source.SourceDocument, return warnings, nil, nil } -func (r *Runner) validateRaw(ctx context.Context, target rawValidationTarget) ([]contracts.Warning, *contracts.RejectedOutput, error) { - if len(target.prepared.validators) == 0 { - return nil, nil, nil - } - - var warnings []contracts.Warning - for index, preparedValidator := range target.prepared.validators { - validator := preparedValidator.legacy - validatorBinding := preparedValidator.resolved - if validator == nil { - return nil, nil, fmt.Errorf("validator %q is not available on the legacy raw path", validatorBinding.Binding.Module) - } - request := target.validationRequest(validatorBinding.Binding) - started := time.Now().UTC() - attemptPath := path.Join("validate", debugPathComponent(string(target.stage)), debugPathComponent(target.laneID), debugPathComponent(target.moduleKey), fmt.Sprintf("%02d-%s-attempt-%02d", index+1, debugPathComponent(validator.Name()), target.attempt)) - validatorCtx, llmScope := withDebugLLMScope(ctx, attemptPath) - result, err := validator.Validate(validatorCtx, request) - debugPayload := debugValidationCall{ - ValidatorName: validator.Name(), - Request: debugValidationRequestEnvelope(request), - Result: debugValidationResultEnvelope(result), - } - if err != nil { - debugPayload.Error = err.Error() - } - if debugErr := writeDebugTimed(target.debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{ - Stage: string(target.stage), - LaneID: target.laneID, - ModuleKey: target.moduleKey, - Attempt: target.attempt, - StartedAt: started, - Payload: debugPayload, - Error: debugPayload.Error, - }, llmScope)); debugErr != nil { - return nil, nil, fmt.Errorf("write validation debug artifact: %w", debugErr) - } - if err != nil { - return nil, nil, fmt.Errorf("validate raw %s output with validator %q: %w", target.stage, validator.Name(), err) - } - if !result.Approved { - reasonCode := strings.TrimSpace(result.ReasonCode) - if reasonCode == "" { - reasonCode = "raw_output_rejected" - } - message := strings.TrimSpace(result.Message) - if message == "" { - message = "raw output rejected" - } - return nil, &contracts.RejectedOutput{ - Stage: string(target.stage), - LaneID: target.laneID, - ModuleKey: target.moduleKey, - ChunkID: target.chunkID, - ChunkIndex: target.chunkIndex, - ValidatorName: validator.Name(), - ReasonCode: reasonCode, - Message: message, - AttemptCount: target.attempt, - DiagnosticArtifactPath: result.DiagnosticArtifactPath, - }, nil - } - warnings = append(warnings, result.Warnings...) - } - return warnings, nil, nil -} - -func (target rawValidationTarget) validationRequest(binding ModuleBinding) contracts.ValidationRequest { - return contracts.ValidationRequest{ - Stage: string(target.stage), - LaneID: target.laneID, - ModuleKey: target.moduleKey, - Source: target.source, - SourceID: target.sourceID, - SourceInput: target.sourceInput.Clone(), - SessionID: target.sessionID, - References: CloneReferenceSet(target.references), - LLMClient: target.llmClient, - LLMProfile: binding.LLMProfile, - Options: cloneOptions(binding.Options), - Metadata: cloneMetadata(target.metadata), - Schema: cloneResponseSchema(target.schema), - Payload: cloneRawPayload(target.payload), - ChunkID: target.chunkID, - ChunkIndex: target.chunkIndex, - Chunk: cloneSourceChunkPtr(target.chunk), - Chunks: cloneSourceChunks(target.chunks), - ExtractOutputs: cloneExtractOutputs(target.extractOutputs), - MergeOutput: cloneMergeOutput(target.mergeOutput), - } -} - func resolvedValidatorChain(stage ModuleStage, laneID string, moduleKey string, chains []ResolvedValidatorChain) ResolvedValidatorChain { for _, chain := range chains { if chain.Stage != stage { @@ -1250,7 +609,7 @@ func validatorChainManifests(chains []ResolvedValidatorChain) []artifacts.Valida func failOutput(output RunOutput) RunOutput { if output.Manifest.PipelineID != "" { - populateRawOutputManifest(&output) + populateOutputManifest(&output) output.Manifest.ValidationStatus = "failed" output.Manifest.CompletedAt = timePtr(time.Now().UTC()) } @@ -1274,7 +633,7 @@ func recordCheckpointEvent(output *RunOutput, loader CheckpointLoader, stage str }) } -func populateRawOutputManifest(output *RunOutput) { +func populateOutputManifest(output *RunOutput) { if output == nil { return } @@ -1325,59 +684,18 @@ func rejectedOutputManifests(rejected []contracts.RejectedOutput) []artifacts.Re return manifests } -func setLaneManifestMetadata(output *RunOutput, laneID string, modules ...any) { - if output == nil { - return - } - for i := range output.Manifest.ArtifactLanes { - if output.Manifest.ArtifactLanes[i].ID != laneID { - continue - } - - metadata := make(map[string]any) - for _, module := range modules { - moduleMetadata, ok := moduleManifestMetadata(module) - if !ok { - continue - } - key := manifestMetadataKey(module) - if key == "" { - continue - } - metadata[key] = moduleMetadata - } - if len(metadata) > 0 { - output.Manifest.ArtifactLanes[i].Metadata = metadata - } - return - } -} - func attachModuleManifestMetadata(output *RunOutput, moduleKey string, module any) { if output == nil { return } - moduleMetadata, ok := moduleManifestMetadata(module) + metadata, ok := moduleManifestMetadata(module) if !ok { return } if output.Manifest.ModuleMetadata == nil { output.Manifest.ModuleMetadata = make(map[string]map[string]any) } - output.Manifest.ModuleMetadata[moduleKey] = moduleMetadata -} - -func manifestMetadataKey(module any) string { - switch module.(type) { - case contracts.LegacyRawExtractor: - return "extractor" - case contracts.LegacyRawMerger: - return "merger" - case contracts.LegacyRawNormalizer: - return "normalizer" - default: - return "" - } + output.Manifest.ModuleMetadata[moduleKey] = metadata } func moduleManifestMetadata(module any) (map[string]any, bool) { @@ -1557,20 +875,6 @@ func cloneWarnings(warnings []contracts.Warning) []contracts.Warning { return append([]contracts.Warning(nil), warnings...) } -func cloneRawPayload(payload contracts.RawPayload) contracts.RawPayload { - return contracts.RawPayload{ - Content: append([]byte(nil), payload.Content...), - MediaType: payload.MediaType, - Metadata: cloneMetadata(payload.Metadata), - Warnings: cloneWarnings(payload.Warnings), - } -} - -func cloneResponseSchema(schema contracts.ResponseSchema) contracts.ResponseSchema { - schema.JSONSchema = append([]byte(nil), schema.JSONSchema...) - return schema -} - func cloneSourceChunkPtr(chunk *source.Chunk) *source.Chunk { if chunk == nil { return nil @@ -1608,35 +912,6 @@ func cloneSourceUnits(units []source.SourceUnit) []source.SourceUnit { return out } -func cloneExtractOutput(output contracts.ExtractOutput) contracts.ExtractOutput { - output.Schema = cloneResponseSchema(output.Schema) - output.Payload = cloneRawPayload(output.Payload) - return output -} - -func cloneExtractOutputs(outputs []contracts.ExtractOutput) []contracts.ExtractOutput { - if len(outputs) == 0 { - return nil - } - out := make([]contracts.ExtractOutput, 0, len(outputs)) - for _, output := range outputs { - out = append(out, cloneExtractOutput(output)) - } - return out -} - -func cloneMergeOutput(output contracts.MergeOutput) contracts.MergeOutput { - output.Schema = cloneResponseSchema(output.Schema) - output.Payload = cloneRawPayload(output.Payload) - return output -} - -func cloneNormalizeOutput(output contracts.NormalizeOutput) contracts.NormalizeOutput { - output.Schema = cloneResponseSchema(output.Schema) - output.Payload = cloneRawPayload(output.Payload) - return output -} - func cloneSerializedOutputs(outputs []contracts.SerializedOutput) []contracts.SerializedOutput { if len(outputs) == 0 { return nil @@ -1648,16 +923,6 @@ func cloneSerializedOutputs(outputs []contracts.SerializedOutput) []contracts.Se return out } -func serializedOutputFromLegacy(output contracts.NormalizeOutput) contracts.SerializedOutput { - return contracts.SerializedOutput{ - LaneID: output.LaneID, NormalizerKey: output.NormalizerKey, SourceID: output.SourceID, - Artifact: contracts.SerializedArtifact{ - Schema: contracts.ArtifactSchema{ID: output.Schema.ID, Name: output.Schema.Name, Version: output.Schema.Version, JSONSchema: append([]byte(nil), output.Schema.JSONSchema...)}, - MediaType: output.Payload.MediaType, Content: append([]byte(nil), output.Payload.Content...), Metadata: cloneMetadata(output.Payload.Metadata), - }, - } -} - func cloneRejectedOutputs(rejected []contracts.RejectedOutput) []contracts.RejectedOutput { if len(rejected) == 0 { return nil diff --git a/internal/framework/pipeline/runner_test.go b/internal/framework/pipeline/runner_test.go deleted file mode 100644 index d3aed3a..0000000 --- a/internal/framework/pipeline/runner_test.go +++ /dev/null @@ -1,2732 +0,0 @@ -package pipeline - -import ( - "context" - "errors" - "reflect" - "strings" - "testing" - "time" - - "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" - "gitea.maximumdirect.net/eric/notarius/internal/core/source" - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -func TestNewAndDataTypes(t *testing.T) { - runner := New() - if runner == nil { - t.Fatal("New() = nil, want runner") - } - - input := RunInput{ - pipeline: resolvedPipeline(), - SourceID: "source-1", - Path: "input.txt", - RawInput: []byte("source text"), - llmClient: fakeLLMClient{}, - Metadata: map[string]any{"request": "test"}, - } - output := RunOutput{ - Manifest: artifacts.RunManifest{PipelineID: "pipeline-1"}, - NormalizeOutputs: []contracts.SerializedOutput{{NormalizerKey: "normalize"}}, - Rejected: []contracts.RejectedOutput{{ValidatorName: "validator"}}, - Warnings: []contracts.Warning{{ReasonCode: "note", Message: "message"}}, - OutputFiles: []contracts.OutputFile{{Name: "outputs/generic.json", ContentType: "application/json", Bytes: []byte(`{}`)}}, - } - - if input.pipeline.ID != "pipeline-1" || input.SourceID != "source-1" { - t.Fatalf("RunInput = %#v, want constructed fields", input) - } - if output.Manifest.PipelineID != "pipeline-1" || len(output.NormalizeOutputs) != 1 || len(output.Rejected) != 1 || len(output.Warnings) != 1 || len(output.OutputFiles) != 1 { - t.Fatalf("RunOutput = %#v, want constructed fields", output) - } -} - -type preparedRunnerHarness struct { - t *testing.T - registries Registries -} - -func newPreparedRunner(t *testing.T, registries Registries) preparedRunnerHarness { - t.Helper() - return preparedRunnerHarness{t: t, registries: registries} -} - -func (h preparedRunnerHarness) Run(ctx context.Context, input RunInput) (RunOutput, error) { - h.t.Helper() - input.llmClient = WithDebugLLMRecording(input.llmClient, input.Debug) - prepared, err := Prepare(input.pipeline, h.registries, ModuleDependencies{LLM: input.llmClient}) - if err != nil { - return RunOutput{}, err - } - input.Prepared = prepared - return New().Run(ctx, input) -} - -func TestRunRejectsInvalidSetup(t *testing.T) { - tests := []struct { - name string - run func() (RunOutput, error) - error string - }{ - { - name: "nil runner", - run: func() (RunOutput, error) { return (*Runner)(nil).Run(context.Background(), RunInput{}) }, - error: "runner must not be nil", - }, - { - name: "nil prepared pipeline", - run: func() (RunOutput, error) { return New().Run(context.Background(), RunInput{}) }, - error: "prepared pipeline must not be nil", - }, - { - name: "empty pipeline id", - run: func() (RunOutput, error) { - return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: ResolvedPipeline{Digest: "sha256:pipeline"}}) - }, - error: "pipeline id", - }, - { - name: "empty pipeline digest", - run: func() (RunOutput, error) { - pipeline := resolvedPipeline() - pipeline.Digest = "" - return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: pipeline}) - }, - error: "pipeline digest", - }, - { - name: "empty artifact lanes", - run: func() (RunOutput, error) { - pipeline := resolvedPipeline() - pipeline.ArtifactLanes = nil - return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: pipeline}) - }, - error: "artifact lanes", - }, - { - name: "missing input registry", - run: func() (RunOutput, error) { - registries := newRunnerRegistries(t, nil) - registries.Inputs = nil - return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - }, - error: "input registry", - }, - { - name: "missing chunker registry", - run: func() (RunOutput, error) { - registries := newRunnerRegistries(t, nil) - registries.Chunkers = nil - return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - }, - error: "chunker registry", - }, - { - name: "missing extractor registry", - run: func() (RunOutput, error) { - registries := newRunnerRegistries(t, nil) - registries.Extractors = nil - return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - }, - error: "extractor registry", - }, - { - name: "missing merger registry", - run: func() (RunOutput, error) { - registries := newRunnerRegistries(t, nil) - registries.Mergers = nil - return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - }, - error: "merger registry", - }, - { - name: "missing normalizer registry", - run: func() (RunOutput, error) { - registries := newRunnerRegistries(t, nil) - registries.Normalizers = nil - return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - }, - error: "normalizer registry", - }, - { - name: "missing output registry", - run: func() (RunOutput, error) { - registries := newRunnerRegistries(t, nil) - registries.Outputs = nil - return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - }, - error: "output encoder registry", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := test.run() - assertRunError(t, err, test.error) - }) - } -} - -func TestRunAllowsNilValidatorRegistryWithoutConfiguredValidators(t *testing.T) { - registries := newRunnerRegistries(t, nil) - registries.Validators = nil - - _, err := newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } -} - -func TestRunRejectsInputBuildParseAndInvalidSourceErrors(t *testing.T) { - buildErr := errors.New("build failed") - parseErr := errors.New("parse failed") - - tests := []struct { - name string - configure func(*runnerModules) - want string - }{ - { - name: "input build", - configure: func(modules *runnerModules) { - modules.inputBuildErr = buildErr - }, - want: "build input adapter", - }, - { - name: "input parse", - configure: func(modules *runnerModules) { - modules.input.err = parseErr - }, - want: "parse input", - }, - { - name: "invalid source", - configure: func(modules *runnerModules) { - modules.input.doc = &source.SourceDocument{} - }, - want: "validate source document", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - modules := defaultRunnerModules() - test.configure(modules) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - - assertRunError(t, err, test.want) - if test.name == "input build" { - if output.Manifest.PipelineID != "" { - t.Fatalf("PipelineID = %q, want no run manifest for preparation failure", output.Manifest.PipelineID) - } - return - } - if output.Manifest.ValidationStatus != "failed" { - t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) - } - }) - } -} - -func TestRunRejectsChunkerBuildChunkAndEmptyChunkErrors(t *testing.T) { - chunkErr := errors.New("chunk failed") - - tests := []struct { - name string - configure func(*runnerModules) - want string - }{ - { - name: "chunker build", - configure: func(modules *runnerModules) { - modules.chunkerBuildErr = errors.New("build failed") - }, - want: "build chunker", - }, - { - name: "chunker chunk", - configure: func(modules *runnerModules) { - modules.chunker.err = chunkErr - }, - want: "chunk source", - }, - { - name: "empty chunks", - configure: func(modules *runnerModules) { - modules.chunker.chunks = nil - }, - want: "returned no chunks", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - modules := defaultRunnerModules() - test.configure(modules) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - - assertRunError(t, err, test.want) - if test.name == "chunker build" { - if output.Manifest.PipelineID != "" { - t.Fatalf("PipelineID = %q, want no run manifest for preparation failure", output.Manifest.PipelineID) - } - return - } - if output.Manifest.ValidationStatus != "failed" { - t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) - } - }) - } -} - -func TestRunRejectsInvalidChunks(t *testing.T) { - tests := []struct { - name string - chunks []source.Chunk - want string - }{ - { - name: "empty chunk id", - chunks: []source.Chunk{chunkWithUnits("", "source-1", 0, unitWithID("u1"))}, - want: "id must not be empty", - }, - { - name: "duplicate chunk id", - chunks: []source.Chunk{ - chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1")), - chunkWithUnits("chunk-0", "source-1", 1, unitWithID("u2")), - }, - want: "duplicated", - }, - { - name: "wrong source id", - chunks: []source.Chunk{chunkWithUnits("chunk-0", "other-source", 0, unitWithID("u1"))}, - want: "source_id", - }, - { - name: "wrong index", - chunks: []source.Chunk{chunkWithUnits("chunk-0", "source-1", 1, unitWithID("u1"))}, - want: "index", - }, - { - name: "missing ref", - chunks: []source.Chunk{func() source.Chunk { - chunk := chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1")) - chunk.Ref = source.SourceRef{} - return chunk - }()}, - want: "source_id", - }, - { - name: "foreign ref", - chunks: []source.Chunk{func() source.Chunk { - chunk := chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1")) - chunk.Ref.SourceID = "other-source" - return chunk - }()}, - want: "does not match document id", - }, - { - name: "unknown start id", - chunks: []source.Chunk{chunkWithRef("chunk-0", "source-1", 0, 9, 1, unitWithID("u1"))}, - want: "start_unit_id", - }, - { - name: "unknown end id", - chunks: []source.Chunk{chunkWithRef("chunk-0", "source-1", 0, 1, 9, unitWithID("u1"))}, - want: "end_unit_id", - }, - { - name: "reversed bounds", - chunks: []source.Chunk{chunkWithRef("chunk-0", "source-1", 0, 2, 1, unitWithID("u1"), unitWithID("u2"))}, - want: "appears after", - }, - { - name: "empty units", - chunks: []source.Chunk{{ID: "chunk-0", SourceID: "source-1", Index: 0, Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, Content: []byte(`{"units":[]}`), MediaType: "application/json"}}, - want: "units must not be empty", - }, - { - name: "empty content", - chunks: []source.Chunk{{ID: "chunk-0", SourceID: "source-1", Index: 0, Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, MediaType: "application/json", Units: []source.SourceUnit{unitWithID("u1")}}}, - want: "content must not be empty", - }, - { - name: "empty media type", - chunks: []source.Chunk{{ID: "chunk-0", SourceID: "source-1", Index: 0, Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, Content: []byte(`{"units":[1]}`), Units: []source.SourceUnit{unitWithID("u1")}}}, - want: "media_type must not be empty", - }, - { - name: "repeated unit inside chunk", - chunks: []source.Chunk{chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1"), unitWithID("u1"))}, - want: "repeats source unit", - }, - { - name: "unknown unit", - chunks: []source.Chunk{chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u9"))}, - want: "was not found", - }, - { - name: "units out of source order", - chunks: []source.Chunk{chunkWithRef("chunk-0", "source-1", 0, 1, 2, unitWithID("u2"), unitWithID("u1"))}, - want: "contiguous range", - }, - { - name: "noncontiguous units", - chunks: []source.Chunk{chunkWithRef("chunk-0", "source-1", 0, 1, 3, unitWithID("u1"), unitWithID("u3"))}, - want: "contiguous range", - }, - { - name: "ref does not match unit span", - chunks: []source.Chunk{chunkWithRef("chunk-0", "source-1", 0, 1, 2, unitWithID("u1"))}, - want: "does not match unit span", - }, - { - name: "unit ref does not match source", - chunks: []source.Chunk{func() source.Chunk { - unit := unitWithID("u1") - unit.Ref.SourceID = "other-source" - return chunkWithRef("chunk-0", "source-1", 0, 1, 1, unit) - }()}, - want: "ref does not match source document", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.chunks = test.chunks - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - - assertRunError(t, err, test.want) - if output.Manifest.ValidationStatus != "failed" { - t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) - } - if len(modules.extractors["extract-alpha"].requests) != 0 { - t.Fatalf("extractor calls = %d, want none after invalid chunks", len(modules.extractors["extract-alpha"].requests)) - } - }) - } -} - -func TestRunAllowsPartialCoverageAndOverlappingChunks(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.chunks = []source.Chunk{ - chunkWithUnits("chunk-0", "source-1", 0, unitWithID("u1"), unitWithID("u2")), - chunkWithUnits("chunk-1", "source-1", 1, unitWithID("u2")), - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - if len(output.NormalizeOutputs) != 1 { - t.Fatalf("len(NormalizeOutputs) = %d, want one lane output", len(output.NormalizeOutputs)) - } -} - -func TestRunCanonicalizesChunkUnitsBeforeExtraction(t *testing.T) { - modules := defaultRunnerModules() - modules.input.doc = sourceDocumentWithUnitMetadata() - modules.chunker.chunks = []source.Chunk{ - { - ID: "chunk-0", - SourceID: "source-1", - Index: 0, - Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, - Content: []byte(`{"units":[{"id":1}]}`), - MediaType: "application/json", - Units: []source.SourceUnit{ - { - ID: 1, - Kind: "mutated-kind", - Text: "mutated text", - Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, - Metadata: map[string]any{ - "speaker": "chunker-speaker", - "note": "chunker note", - }, - }, - }, - }, - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(output.NormalizeOutputs) != 1 { - t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs)) - } - - extractor := modules.extractors["extract-alpha"] - if len(extractor.requests) != 1 { - t.Fatalf("len(extractor requests) = %d, want 1", len(extractor.requests)) - } - chunk := extractor.requests[0].Chunk - if chunk == nil { - t.Fatal("extractor chunk = nil, want canonical chunk") - } - if chunk.Units[0].ID != 1 || chunk.Units[0].Kind != "source-kind" || chunk.Units[0].Text != "source text" { - t.Fatalf("chunk unit = %#v, want source document unit values", chunk.Units[0]) - } - if got := chunk.Units[0].Ref; got != modules.input.doc.Units[0].Ref { - t.Fatalf("chunk unit ref = %#v, want source document ref %#v", got, modules.input.doc.Units[0].Ref) - } - if got := chunk.Units[0].Metadata["speaker"]; got != "source-speaker" { - t.Fatalf("chunk unit metadata = %#v, want source document metadata", chunk.Units[0].Metadata) - } - if got := chunk.Units[0].Metadata["topic"]; got != "source-topic" { - t.Fatalf("chunk unit metadata = %#v, want cloned source document metadata", chunk.Units[0].Metadata) - } - - modules.input.doc.Units[0].Kind = "changed-kind" - modules.input.doc.Units[0].Text = "changed text" - modules.input.doc.Units[0].Ref.SourceID = "changed-source" - modules.input.doc.Units[0].Metadata["speaker"] = "changed-speaker" - if chunk.Units[0].Kind != "source-kind" || chunk.Units[0].Text != "source text" || chunk.Units[0].Ref.SourceID != "source-1" || chunk.Units[0].Metadata["speaker"] != "source-speaker" { - t.Fatalf("chunk unit changed after source mutation: %#v", chunk.Units[0]) - } -} - -func TestRunPreservesChunkMetadataDuringCanonicalization(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.chunks = []source.Chunk{ - { - ID: "chunk-0", - SourceID: "source-1", - Index: 0, - Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, - Content: []byte(`{"units":[{"id":1}]}`), - MediaType: "application/json", - Units: []source.SourceUnit{ - unitWithID("u1"), - }, - Metadata: map[string]any{ - "scene_title": "Original scene", - "boundary_note": "Chunker note", - }, - }, - } - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - extractor := modules.extractors["extract-alpha"] - if len(extractor.requests) != 1 || extractor.requests[0].Chunk == nil { - t.Fatalf("extractor requests = %#v, want one canonical chunk", extractor.requests) - } - if got := extractor.requests[0].Chunk.Metadata["scene_title"]; got != "Original scene" { - t.Fatalf("chunk metadata = %#v, want chunker metadata", extractor.requests[0].Chunk.Metadata) - } - if got := extractor.requests[0].Chunk.Metadata["boundary_note"]; got != "Chunker note" { - t.Fatalf("chunk metadata = %#v, want chunker metadata", extractor.requests[0].Chunk.Metadata) - } - - modules.chunker.chunks[0].Metadata["scene_title"] = "changed" - modules.chunker.chunks[0].Metadata["boundary_note"] = "changed" - if got := extractor.requests[0].Chunk.Metadata["scene_title"]; got != "Original scene" { - t.Fatalf("chunk metadata aliased to chunker map: %#v", extractor.requests[0].Chunk.Metadata) - } - if got := extractor.requests[0].Chunk.Metadata["boundary_note"]; got != "Chunker note" { - t.Fatalf("chunk metadata aliased to chunker map: %#v", extractor.requests[0].Chunk.Metadata) - } -} - -func TestRunExecutesChunksAndUsesConstructedLLMClient(t *testing.T) { - modules := defaultRunnerModules() - llmClient := fakeLLMClient{} - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - llmClient: llmClient, - Metadata: map[string]any{"request": "test"}, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - extractor := modules.extractors["extract-alpha"] - if !reflect.DeepEqual(extractor.seenChunkIDs, []string{"chunk-0", "chunk-1"}) { - t.Fatalf("seen chunks = %#v, want both chunks", extractor.seenChunkIDs) - } - if len(modules.chunker.requests) != 1 || modules.chunker.llmClient == nil { - t.Fatalf("chunker LLM client = %#v, want injected client", modules.chunker.llmClient) - } - if len(extractor.seenLLMClients) != 2 || extractor.seenLLMClients[0] == nil || extractor.seenLLMClients[1] == nil { - t.Fatalf("seen LLM clients = %#v, want client for each chunk", extractor.seenLLMClients) - } - normalizer := modules.normalizers["normalize"] - if len(normalizer.requests) != 1 || normalizer.requests[0].LLMClient == nil { - t.Fatalf("normalizer LLM client = %#v, want client on normalize request", normalizer.requests) - } - if len(modules.mergers["merge"].requests) != 1 || modules.mergers["merge"].requests[0].LLMClient == nil { - t.Fatalf("merger LLM client = %#v, want client on merge request", modules.mergers["merge"].requests) - } - if extractor.seenMetadata[0]["request"] != "test" { - t.Fatalf("seen metadata = %#v, want request metadata", extractor.seenMetadata) - } - if len(output.NormalizeOutputs) != 1 { - t.Fatalf("len(NormalizeOutputs) = %d, want one lane output", len(output.NormalizeOutputs)) - } -} - -func TestRunPassesSourceInputAndSessionIDToPromptCapableStages(t *testing.T) { - modules := defaultRunnerModules() - rawInput := []byte("{\"source\":\"exact bytes\"}") - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - Path: "session.json", - RawInput: rawInput, - SessionID: " explicit-session ", - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if got := output.Manifest.Metadata["session_id"]; got != "explicit-session" { - t.Fatalf("manifest metadata = %#v, want session_id", output.Manifest.Metadata) - } - - sourceRequests := []struct { - name string - material contracts.LLMInputMaterial - sessionID string - }{ - {name: "chunk", material: modules.chunker.requests[0].SourceInput, sessionID: modules.chunker.requests[0].SessionID}, - {name: "merge", material: modules.mergers["merge"].requests[0].SourceInput, sessionID: modules.mergers["merge"].requests[0].SessionID}, - {name: "normalize", material: modules.normalizers["normalize"].requests[0].SourceInput, sessionID: modules.normalizers["normalize"].requests[0].SessionID}, - } - for _, req := range sourceRequests { - if req.sessionID != "explicit-session" { - t.Fatalf("%s session ID = %q, want explicit-session", req.name, req.sessionID) - } - if got := string(req.material.Content); got != string(rawInput) { - t.Fatalf("%s source input content = %q, want exact raw input", req.name, got) - } - if req.material.Name != "source" || req.material.MediaType != "application/json" || req.material.SizeBytes != int64(len(rawInput)) { - t.Fatalf("%s source input = %#v, want source metadata", req.name, req.material) - } - if req.material.Digest != sourceInputDigest(rawInput) { - t.Fatalf("%s digest = %q, want %q", req.name, req.material.Digest, sourceInputDigest(rawInput)) - } - if !strings.HasPrefix(req.material.OriginURI, "file://") || !strings.HasSuffix(req.material.OriginURI, "/session.json") { - t.Fatalf("%s origin URI = %q, want file URI ending in session.json", req.name, req.material.OriginURI) - } - } - for i, req := range modules.extractors["extract-alpha"].requests { - if req.SessionID != "explicit-session" { - t.Fatalf("extract %d session ID = %q, want explicit-session", i, req.SessionID) - } - if req.Chunk == nil { - t.Fatalf("extract %d chunk = nil, want chunk", i) - } - if got := string(req.SourceInput.Content); got != string(req.Chunk.Content) { - t.Fatalf("extract %d source input content = %q, want chunk content %q", i, got, req.Chunk.Content) - } - if req.SourceInput.Name != "source" || req.SourceInput.MediaType != req.Chunk.MediaType || req.SourceInput.SizeBytes != int64(len(req.Chunk.Content)) { - t.Fatalf("extract %d source input = %#v, want chunk metadata", i, req.SourceInput) - } - if req.SourceInput.Digest != sourceInputDigest(req.Chunk.Content) { - t.Fatalf("extract %d digest = %q, want %q", i, req.SourceInput.Digest, sourceInputDigest(req.Chunk.Content)) - } - if !strings.HasPrefix(req.SourceInput.OriginURI, "file://") || !strings.HasSuffix(req.SourceInput.OriginURI, "/session.json") { - t.Fatalf("extract %d origin URI = %q, want file URI ending in session.json", i, req.SourceInput.OriginURI) - } - } - - modules.chunker.requests[0].SourceInput.Content[0] = 'X' - if got := string(modules.extractors["extract-alpha"].requests[0].SourceInput.Content); got != string(modules.extractors["extract-alpha"].requests[0].Chunk.Content) { - t.Fatalf("source input content aliased across requests: %q", got) - } - if got := string(rawInput); got != "{\"source\":\"exact bytes\"}" { - t.Fatalf("raw input mutated through request material: %q", got) - } -} - -func TestRunDefaultsSessionIDFromParsedSourceDocumentID(t *testing.T) { - modules := defaultRunnerModules() - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - Path: "notes.unknown", - RawInput: []byte("notes"), - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if got := modules.chunker.requests[0].SessionID; got != "source-1" { - t.Fatalf("chunk session ID = %q, want parsed source document ID", got) - } - if got := output.Manifest.Metadata["session_id"]; got != "source-1" { - t.Fatalf("manifest metadata = %#v, want default session id", output.Manifest.Metadata) - } - if got := modules.chunker.requests[0].SourceInput.MediaType; got != unknownMediaType { - t.Fatalf("source input media type = %q, want fallback %q", got, unknownMediaType) - } -} - -func TestRunPassesInputRequestFields(t *testing.T) { - modules := defaultRunnerModules() - metadata := map[string]any{"request": "test"} - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - SourceID: "source-1", - Path: "input.txt", - RawInput: []byte("source text"), - Metadata: metadata, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(modules.input.requests) != 1 { - t.Fatalf("len(input requests) = %d, want 1", len(modules.input.requests)) - } - req := modules.input.requests[0] - if req.SourceID != "source-1" || req.Path != "input.txt" || string(req.Raw) != "source text" { - t.Fatalf("ParseRequest = %#v, want source id, path, and raw input", req) - } - if req.Metadata["request"] != "test" { - t.Fatalf("ParseRequest.Metadata = %#v, want request metadata", req.Metadata) - } -} - -func TestRunPassesModuleBindingConfigToStageRequests(t *testing.T) { - modules := defaultRunnerModules() - pipeline := resolvedPipeline() - pipeline.Input = ModuleBinding{Module: "input", LLMProfile: "input-profile", Options: map[string]any{"input_option": "input-value"}} - pipeline.Chunk = ModuleBinding{Module: "chunk", LLMProfile: "chunk-profile", Options: map[string]any{"chunk_option": "chunk-value"}} - pipeline.Output = ModuleBinding{Module: "output", LLMProfile: "output-profile", Options: map[string]any{"output_option": "output-value"}} - pipeline.ArtifactLanes[0].Extract = ModuleBinding{Module: "extract-alpha", LLMProfile: "extract-profile", Options: map[string]any{"extract_option": "extract-value"}} - pipeline.ArtifactLanes[0].Merge = ModuleBinding{Module: "merge", LLMProfile: "merge-profile", Options: map[string]any{"merge_option": "merge-value"}} - pipeline.ArtifactLanes[0].Normalize = ModuleBinding{Module: "normalize", LLMProfile: "normalize-profile", Options: map[string]any{"normalize_option": "normalize-value"}} - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if got := modules.input.requests[0].LLMProfile; got != "input-profile" { - t.Fatalf("input LLMProfile = %q, want input-profile", got) - } - if got := modules.input.buildOptions["input_option"]; got != "input-value" { - t.Fatalf("input construction options = %#v, want input option", modules.input.buildOptions) - } - if got := modules.chunker.requests[0].LLMProfile; got != "chunk-profile" { - t.Fatalf("chunk LLMProfile = %q, want chunk-profile", got) - } - if got := modules.chunker.buildOptions["chunk_option"]; got != "chunk-value" { - t.Fatalf("chunk construction options = %#v, want chunk option", modules.chunker.buildOptions) - } - if got := modules.extractors["extract-alpha"].requests[0].LLMProfile; got != "extract-profile" { - t.Fatalf("extract LLMProfile = %q, want extract-profile", got) - } - if got := modules.extractors["extract-alpha"].requests[0].Options["extract_option"]; got != "extract-value" { - t.Fatalf("extract Options = %#v, want extract option", modules.extractors["extract-alpha"].requests[0].Options) - } - if got := modules.mergers["merge"].requests[0].LLMProfile; got != "merge-profile" { - t.Fatalf("merge LLMProfile = %q, want merge-profile", got) - } - if got := modules.mergers["merge"].requests[0].Options["merge_option"]; got != "merge-value" { - t.Fatalf("merge Options = %#v, want merge option", modules.mergers["merge"].requests[0].Options) - } - if got := modules.normalizers["normalize"].requests[0].LLMProfile; got != "normalize-profile" { - t.Fatalf("normalize LLMProfile = %q, want normalize-profile", got) - } - if got := modules.normalizers["normalize"].requests[0].Options["normalize_option"]; got != "normalize-value" { - t.Fatalf("normalize Options = %#v, want normalize option", modules.normalizers["normalize"].requests[0].Options) - } - if got := modules.output.requests[0].LLMProfile; got != "output-profile" { - t.Fatalf("output LLMProfile = %q, want output-profile", got) - } - if got := modules.output.buildOptions["output_option"]; got != "output-value" { - t.Fatalf("output construction options = %#v, want output option", modules.output.buildOptions) - } -} - -func TestRunPassesLaneReferencesToExtractorRequests(t *testing.T) { - modules := defaultRunnerModules() - pipeline := resolvedPipeline() - pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "reference text") - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - req := modules.extractors["extract-alpha"].requests[0] - item := req.References.Slots["roster"].Items[0] - if string(item.Content) != "reference text" { - t.Fatalf("reference content = %q, want reference text", item.Content) - } - item.Content[0] = 'R' - if got := string(pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet.Slots["roster"].Items[0].Content); got != "reference text" { - t.Fatalf("runner mutated reference set content = %q", got) - } -} - -func TestRunPassesMergeReferencesToMergerRequest(t *testing.T) { - modules := defaultRunnerModules() - pipeline := resolvedPipeline() - pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet = testReferenceSet("merge_notes", "merge reference text") - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - req := modules.mergers["merge"].requests[0] - item := req.References.Slots["merge_notes"].Items[0] - if string(item.Content) != "merge reference text" { - t.Fatalf("merge reference content = %q, want merge reference text", item.Content) - } - item.Content[0] = 'M' - if got := string(pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet.Slots["merge_notes"].Items[0].Content); got != "merge reference text" { - t.Fatalf("runner mutated merge reference set content = %q", got) - } -} - -func TestRunPassesChunkReferencesToChunkerRequest(t *testing.T) { - modules := defaultRunnerModules() - pipeline := resolvedPipeline() - pipeline.ChunkReferences.ReferenceSet = testReferenceSet("scene_guide", "chunk reference text") - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - req := modules.chunker.requests[0] - item := req.References.Slots["scene_guide"].Items[0] - if string(item.Content) != "chunk reference text" { - t.Fatalf("chunk reference content = %q, want chunk reference text", item.Content) - } - item.Content[0] = 'C' - if got := string(pipeline.ChunkReferences.ReferenceSet.Slots["scene_guide"].Items[0].Content); got != "chunk reference text" { - t.Fatalf("runner mutated chunk reference set content = %q", got) - } -} - -func TestRunPassesNormalizeReferencesToNormalizerRequest(t *testing.T) { - modules := defaultRunnerModules() - pipeline := resolvedPipeline() - pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet = testReferenceSet("normalization_notes", "normalize reference text") - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - req := modules.normalizers["normalize"].requests[0] - item := req.References.Slots["normalization_notes"].Items[0] - if string(item.Content) != "normalize reference text" { - t.Fatalf("normalize reference content = %q, want normalize reference text", item.Content) - } - item.Content[0] = 'N' - if got := string(pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet.Slots["normalization_notes"].Items[0].Content); got != "normalize reference text" { - t.Fatalf("runner mutated normalize reference set content = %q", got) - } -} - -func TestRunPassesValidationRequestContextToValidators(t *testing.T) { - modules := defaultRunnerModules() - chunkValidator := &runnerChainValidator{name: "chain-chunk"} - extractValidator := &runnerChainValidator{name: "chain-extract", executionClass: contracts.ExecutionClassLLMBacked} - mergeValidator := &runnerChainValidator{name: "chain-merge"} - normalizeValidator := &runnerChainValidator{name: "chain-normalize"} - modules.validators[chunkValidator.name] = chunkValidator - modules.validators[extractValidator.name] = extractValidator - modules.validators[mergeValidator.name] = mergeValidator - modules.validators[normalizeValidator.name] = normalizeValidator - - pipeline := resolvedPipeline() - pipeline.ChunkReferences.ReferenceSet = testReferenceSet("scene_guide", "chunk reference text") - pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "extract reference text") - pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet = testReferenceSet("merge_notes", "merge reference text") - pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet = testReferenceSet("normalization_notes", "normalize reference text") - setResolvedValidatorChain(t, &pipeline, StageChunk, "", "chunk", resolvedValidatorForTest(chunkValidator)) - setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", ResolvedValidator{ - Binding: ModuleBinding{Module: extractValidator.name, LLMProfile: "validator-profile", Options: map[string]any{"strict": true}}, - ExecutionClass: extractValidator.ExecutionClass(), - }) - setResolvedValidatorChain(t, &pipeline, StageMerge, "alpha", "merge", resolvedValidatorForTest(mergeValidator)) - setResolvedValidatorChain(t, &pipeline, StageNormalize, "alpha", "normalize", resolvedValidatorForTest(normalizeValidator)) - - rawInput := []byte("{\"source\":\"exact bytes\"}") - llmClient := fakeLLMClient{} - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: pipeline, - Path: "session.json", - RawInput: rawInput, - llmClient: llmClient, - SessionID: "session-123", - Metadata: map[string]any{"request": "test"}, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(chunkValidator.requests) != 1 { - t.Fatalf("chunk validator requests = %d, want one collection request", len(chunkValidator.requests)) - } - chunkReq := chunkValidator.requests[0] - if chunkReq.Stage != string(StageChunk) || chunkReq.ModuleKey != "chunk" || chunkReq.SourceID != "source-1" || chunkReq.SessionID != "session-123" { - t.Fatalf("chunk validation request = %#v, want stage/module/source/session provenance", chunkReq) - } - if chunkReq.LLMClient == nil || string(chunkReq.SourceInput.Content) != string(rawInput) { - t.Fatalf("chunk validation source/client = %#v, want full source input and LLM client", chunkReq.SourceInput) - } - if chunkReq.Chunk != nil || chunkReq.ChunkID != "" || len(chunkReq.Chunks) != 2 || chunkReq.Chunks[0].ID != "chunk-0" || chunkReq.Chunks[1].ID != "chunk-1" { - t.Fatalf("chunk validation chunk fields = chunk=%#v chunk_id=%q chunks=%#v, want whole chunk collection", chunkReq.Chunk, chunkReq.ChunkID, chunkReq.Chunks) - } - chunkReq.Chunks[0].Content[0] = 'X' - if got := string(modules.chunker.chunks[0].Content); got == string(chunkReq.Chunks[0].Content) { - t.Fatalf("chunk validation chunks alias module output content = %q", got) - } - if item := chunkReq.References.Slots["scene_guide"].Items[0]; string(item.Content) != "chunk reference text" { - t.Fatalf("chunk validation references = %#v, want chunk references", chunkReq.References) - } - - if len(extractValidator.requests) != 2 { - t.Fatalf("extract validator requests = %d, want one per chunk", len(extractValidator.requests)) - } - extractReq := extractValidator.requests[0] - if extractReq.Stage != string(StageExtract) || extractReq.LaneID != "alpha" || extractReq.ModuleKey != "extract-alpha" || extractReq.ChunkID != "chunk-0" || extractReq.ChunkIndex != 0 { - t.Fatalf("extract validation request = %#v, want extract provenance", extractReq) - } - if extractReq.LLMProfile != "validator-profile" || extractReq.Options["strict"] != true || extractReq.Metadata["request"] != "test" { - t.Fatalf("extract validator binding fields = profile %q options %#v metadata %#v", extractReq.LLMProfile, extractReq.Options, extractReq.Metadata) - } - if extractReq.Chunk == nil || string(extractReq.SourceInput.Content) != string(extractReq.Chunk.Content) { - t.Fatalf("extract source input = %#v chunk=%#v, want chunk material", extractReq.SourceInput, extractReq.Chunk) - } - if item := extractReq.References.Slots["roster"].Items[0]; string(item.Content) != "extract reference text" { - t.Fatalf("extract validation references = %#v, want extract references", extractReq.References) - } - - if len(mergeValidator.requests) != 1 { - t.Fatalf("merge validator requests = %d, want one", len(mergeValidator.requests)) - } - mergeReq := mergeValidator.requests[0] - if mergeReq.Stage != string(StageMerge) || mergeReq.LaneID != "alpha" || len(mergeReq.ExtractOutputs) != 2 { - t.Fatalf("merge validation request = %#v, want lane and extract outputs", mergeReq) - } - if mergeReq.ExtractOutputs[0].ChunkID != "chunk-0" || string(mergeReq.SourceInput.Content) != string(rawInput) { - t.Fatalf("merge validation upstream/source = %#v source=%#v, want ordered extracts and source input", mergeReq.ExtractOutputs, mergeReq.SourceInput) - } - if item := mergeReq.References.Slots["merge_notes"].Items[0]; string(item.Content) != "merge reference text" { - t.Fatalf("merge validation references = %#v, want merge references", mergeReq.References) - } - - if len(normalizeValidator.requests) != 1 { - t.Fatalf("normalize validator requests = %d, want one", len(normalizeValidator.requests)) - } - normalizeReq := normalizeValidator.requests[0] - if normalizeReq.Stage != string(StageNormalize) || normalizeReq.LaneID != "alpha" || string(normalizeReq.MergeOutput.Payload.Content) != `{"merged":true}` { - t.Fatalf("normalize validation request = %#v, want merge output context", normalizeReq) - } - if item := normalizeReq.References.Slots["normalization_notes"].Items[0]; string(item.Content) != "normalize reference text" { - t.Fatalf("normalize validation references = %#v, want normalize references", normalizeReq.References) - } - - chunkReq.Chunks[0].Content[0] = 'Y' - if got := string(modules.chunker.chunks[0].Content); got != `{"units":[{"id":1,"kind":"unit","text":"Source unit."}]}` { - t.Fatalf("validator request mutated original chunk content: %q", got) - } -} - -func TestRunAllowsNilLLMClientWhenModulesDoNotUseIt(t *testing.T) { - _, err := newPreparedRunner(t, newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil with nil LLM client when modules do not use it", err) - } -} - -func TestRunIncludesInputWarnings(t *testing.T) { - modules := defaultRunnerModules() - warning := contracts.Warning{Scope: "reference", ReasonCode: "empty_reference", Message: "empty reference"} - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - Warnings: []contracts.Warning{warning}, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - if len(output.Warnings) != 1 || output.Warnings[0] != warning { - t.Fatalf("warnings = %#v, want input warning", output.Warnings) - } -} - -func TestRunRecordsTopLevelModuleMetadataForSingletonModules(t *testing.T) { - modules := defaultRunnerModules() - modules.input.manifestMetadata = map[string]any{ - "input_profile": "input-metadata", - } - modules.chunker.manifestMetadata = map[string]any{ - "prompt_id": "dnd.scenes", - "prompt_version": "v1", - "prompt_sha256": "sha256:chunker-prompt", - "response_schema_key": "dnd_scenes", - "response_schema_id": "schema-dnd-scenes", - "response_schema_name": "dnd_scenes", - } - modules.output.manifestMetadata = map[string]any{ - "output_profile": "output-metadata", - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if output.Manifest.ModuleMetadata == nil { - t.Fatal("ModuleMetadata = nil, want module metadata map") - } - if got := output.Manifest.ModuleMetadata["input"]; !reflect.DeepEqual(got, modules.input.manifestMetadata) { - t.Fatalf("input module metadata = %#v, want %#v", got, modules.input.manifestMetadata) - } - if got := output.Manifest.ModuleMetadata["chunker"]; !reflect.DeepEqual(got, modules.chunker.manifestMetadata) { - t.Fatalf("chunker module metadata = %#v, want %#v", got, modules.chunker.manifestMetadata) - } - if got := output.Manifest.ModuleMetadata["output"]; !reflect.DeepEqual(got, modules.output.manifestMetadata) { - t.Fatalf("output module metadata = %#v, want %#v", got, modules.output.manifestMetadata) - } - - modules.chunker.manifestMetadata["prompt_id"] = "changed" - if output.Manifest.ModuleMetadata["chunker"]["prompt_id"] != "dnd.scenes" { - t.Fatalf("chunker module metadata aliased to provider map: %#v", output.Manifest.ModuleMetadata["chunker"]) - } -} - -func TestRunPassesPerChunkRawOutputsToMergeAndNormalize(t *testing.T) { - modules := defaultRunnerModules() - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - merger := modules.mergers["merge"] - if len(merger.requests) != 1 { - t.Fatalf("len(merge requests) = %d, want 1", len(merger.requests)) - } - extractOutputs := merger.requests[0].ExtractOutputs - if len(extractOutputs) != 2 { - t.Fatalf("len(ExtractOutputs) = %d, want 2", len(extractOutputs)) - } - if extractOutputs[0].ChunkID != "chunk-0" || extractOutputs[1].ChunkID != "chunk-1" { - t.Fatalf("merge chunks = %#v, want chunk order", extractOutputs) - } - if extractOutputs[0].ChunkIndex != 0 || string(extractOutputs[0].Payload.Content) != `{"chunk":"chunk-0"}` { - t.Fatalf("first extract output = %#v, want first chunk payload", extractOutputs[0]) - } - if extractOutputs[1].ChunkIndex != 1 || string(extractOutputs[1].Payload.Content) != `{"chunk":"chunk-1"}` { - t.Fatalf("second extract output = %#v, want second chunk payload", extractOutputs[1]) - } - - normalizer := modules.normalizers["normalize"] - if len(normalizer.requests) != 1 { - t.Fatalf("len(normalize requests) = %d, want 1", len(normalizer.requests)) - } - if string(normalizer.requests[0].MergeOutput.Payload.Content) != `{"merged":true}` { - t.Fatalf("normalize merge output = %#v, want merged raw output", normalizer.requests[0].MergeOutput) - } -} - -func TestRunPassesChunkContentAndMediaTypeToExtractors(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.chunks = []source.Chunk{ - sourceChunkWithContent("chunk-0", 0, []byte(`{"chunk":0}`), "application/vnd.test+json"), - } - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - req := modules.extractors["extract-alpha"].requests[0] - if req.Chunk == nil { - t.Fatal("extractor chunk = nil, want chunk") - } - if got := string(req.Chunk.Content); got != `{"chunk":0}` { - t.Fatalf("chunk content = %q, want raw chunk content", got) - } - if req.Chunk.MediaType != "application/vnd.test+json" { - t.Fatalf("chunk media type = %q, want application/vnd.test+json", req.Chunk.MediaType) - } -} - -func TestRunDoesNotPassCheckpointPathsToModules(t *testing.T) { - modules := defaultRunnerModules() - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - Checkpoints: NoopCheckpointRecorder(), - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - for _, req := range modules.input.requests { - assertNoCheckpointMetadata(t, req.Metadata) - } - for _, req := range modules.chunker.requests { - assertNoCheckpointMetadata(t, req.Metadata) - } - for _, req := range modules.extractors["extract-alpha"].requests { - assertNoCheckpointMetadata(t, req.Metadata) - } - for _, req := range modules.mergers["merge"].requests { - assertNoCheckpointMetadata(t, req.Metadata) - } - for _, req := range modules.normalizers["normalize"].requests { - assertNoCheckpointMetadata(t, req.Metadata) - } - for _, req := range modules.output.requests { - assertNoCheckpointMetadata(t, req.Metadata) - } -} - -func TestCheckpointChunkDigestIncludesCanonicalReference(t *testing.T) { - chunk := sourceChunkWithID("chunk-0", 0) - first, err := joinedChunkDigest([]source.Chunk{chunk}) - if err != nil { - t.Fatalf("joinedChunkDigest() error = %v, want nil", err) - } - - chunk.Ref.EndUnitID = 2 - second, err := joinedChunkDigest([]source.Chunk{chunk}) - if err != nil { - t.Fatalf("joinedChunkDigest(changed ref) error = %v, want nil", err) - } - if first == second { - t.Fatalf("checkpoint chunk digests = %q and %q, want provenance change to alter dependency identity", first, second) - } -} - -func TestRunReusesCheckpointedWorkflowOutputs(t *testing.T) { - modules := defaultRunnerModules() - doc := validSourceDocument() - chunks := []source.Chunk{sourceChunkWithID("chunk-0", 0)} - extractOutput := contracts.ExtractOutput{ - LaneID: "alpha", - ExtractorKey: "extract-alpha", - SourceID: doc.ID, - ChunkID: "chunk-0", - ChunkIndex: 0, - Payload: contracts.RawPayload{ - Content: []byte(`{"cached_extract":true}`), - MediaType: "application/json", - }, - } - mergeOutput := contracts.MergeOutput{ - LaneID: "alpha", - MergerKey: "merge", - SourceID: doc.ID, - Payload: contracts.RawPayload{ - Content: []byte(`{"cached_merge":true}`), - MediaType: "application/json", - }, - } - normalizeOutput := contracts.NormalizeOutput{ - LaneID: "alpha", - NormalizerKey: "normalize", - SourceID: doc.ID, - Payload: contracts.RawPayload{ - Content: []byte(`{"cached_normalize":true}`), - MediaType: "application/json", - }, - } - loader := &runnerCheckpointLoader{ - source: SourceCheckpoint{Document: doc}, - chunk: ChunkCheckpoint{Chunks: chunks}, - extract: ExtractCheckpoint{Outputs: []contracts.ExtractOutput{extractOutput}}, - merge: MergeCheckpoint{Output: mergeOutput}, - normalize: NormalizeCheckpoint{Output: normalizeOutput}, - reuse: map[string]bool{ - "source": true, - "chunk": true, - "extract": true, - "merge": true, - "normalize": true, - }, - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - Checkpoint: loader, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(modules.input.requests) != 0 || len(modules.chunker.requests) != 0 || len(modules.extractors["extract-alpha"].requests) != 0 || len(modules.mergers["merge"].requests) != 0 || len(modules.normalizers["normalize"].requests) != 0 { - t.Fatalf("module requests = input:%d chunk:%d extract:%d merge:%d normalize:%d, want all skipped", len(modules.input.requests), len(modules.chunker.requests), len(modules.extractors["extract-alpha"].requests), len(modules.mergers["merge"].requests), len(modules.normalizers["normalize"].requests)) - } - if len(output.NormalizeOutputs) != 1 || string(output.NormalizeOutputs[0].Artifact.Content) != `{"cached_normalize":true}` { - t.Fatalf("NormalizeOutputs = %#v, want cached normalize output", output.NormalizeOutputs) - } - if len(output.CheckpointEvents) != 5 { - t.Fatalf("checkpoint events = %#v, want one per reusable workflow step", output.CheckpointEvents) - } - for _, event := range output.CheckpointEvents { - if event.Action != "reused" { - t.Fatalf("checkpoint event = %#v, want reused", event) - } - } -} - -func TestRunPreservesCheckpointedExtractRejections(t *testing.T) { - modules := defaultRunnerModules() - extractOutput := contracts.ExtractOutput{ - LaneID: "alpha", - ExtractorKey: "extract-alpha", - SourceID: "source-1", - ChunkID: "chunk-1", - ChunkIndex: 1, - Payload: contracts.RawPayload{ - Content: []byte(`{"cached_extract":true}`), - MediaType: "application/json", - }, - } - rejected := contracts.RejectedOutput{ - Stage: string(StageExtract), - LaneID: "alpha", - ModuleKey: "extract-alpha", - ChunkID: "chunk-0", - ReasonCode: "invalid_shape", - Message: "invalid extract", - } - loader := &runnerCheckpointLoader{ - extract: ExtractCheckpoint{ - Outputs: []contracts.ExtractOutput{extractOutput}, - Rejected: []contracts.RejectedOutput{rejected}, - }, - reuse: map[string]bool{"extract": true}, - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - Checkpoint: loader, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(modules.extractors["extract-alpha"].requests) != 0 { - t.Fatalf("extract requests = %d, want reused checkpoint", len(modules.extractors["extract-alpha"].requests)) - } - if len(output.Rejected) != 1 || output.Rejected[0].ChunkID != "chunk-0" { - t.Fatalf("rejected outputs = %#v, want checkpointed extract rejection", output.Rejected) - } - mergeRequests := modules.mergers["merge"].requests - if len(mergeRequests) != 1 || len(mergeRequests[0].ExtractOutputs) != 1 || mergeRequests[0].ExtractOutputs[0].ChunkID != "chunk-1" { - t.Fatalf("merge extract outputs = %#v, want only checkpointed accepted extract", mergeRequests) - } -} - -func TestRunOmitsRejectedExtractOutputsFromMerge(t *testing.T) { - modules := defaultRunnerModules() - validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false, true}, reason: "bad_extract", message: "extract rejected"} - modules.validators[validator.name] = validator - pipeline := resolvedPipeline() - setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(output.Rejected) != 1 || output.Rejected[0].Stage != string(StageExtract) || output.Rejected[0].ChunkID != "chunk-0" { - t.Fatalf("rejected outputs = %#v, want rejected first extract", output.Rejected) - } - extractOutputs := modules.mergers["merge"].requests[0].ExtractOutputs - if len(extractOutputs) != 1 || extractOutputs[0].ChunkID != "chunk-1" { - t.Fatalf("merge extract outputs = %#v, want only accepted second chunk", extractOutputs) - } - if output.Manifest.ValidationStatus != "rejected" { - t.Fatalf("ValidationStatus = %q, want rejected", output.Manifest.ValidationStatus) - } -} - -func TestRunOmitsLaneWithNoAcceptedExtractOutputs(t *testing.T) { - modules := defaultRunnerModules() - validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false}, reason: "bad_extract", message: "extract rejected"} - modules.validators[validator.name] = validator - pipeline := resolvedPipeline() - setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(output.Rejected) != 2 { - t.Fatalf("len(Rejected) = %d, want one rejected record per chunk", len(output.Rejected)) - } - if len(modules.mergers["merge"].requests) != 0 { - t.Fatalf("merge requests = %d, want none", len(modules.mergers["merge"].requests)) - } - if len(modules.normalizers["normalize"].requests) != 0 { - t.Fatalf("normalize requests = %d, want none", len(modules.normalizers["normalize"].requests)) - } - if len(modules.output.requests) != 1 || len(modules.output.requests[0].NormalizeOutputs) != 0 { - t.Fatalf("output normalize outputs = %#v, want none", modules.output.requests) - } -} - -func TestRunRejectedMergePreventsNormalizeForLane(t *testing.T) { - modules := defaultRunnerModules() - validator := &runnerChainValidator{name: "chain-merge", approved: []bool{false}, reason: "bad_merge", message: "merge rejected"} - modules.validators[validator.name] = validator - pipeline := resolvedPipeline() - setResolvedValidatorChain(t, &pipeline, StageMerge, "alpha", "merge", resolvedValidatorForTest(validator)) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(output.Rejected) != 1 || output.Rejected[0].Stage != string(StageMerge) { - t.Fatalf("rejected outputs = %#v, want rejected merge", output.Rejected) - } - if len(modules.normalizers["normalize"].requests) != 0 { - t.Fatalf("normalize requests = %d, want none", len(modules.normalizers["normalize"].requests)) - } - if len(modules.output.requests[0].NormalizeOutputs) != 0 { - t.Fatalf("output normalize outputs = %#v, want none", modules.output.requests[0].NormalizeOutputs) - } -} - -func TestRunRejectedNormalizePreventsOutputForLane(t *testing.T) { - modules := defaultRunnerModules() - validator := &runnerChainValidator{name: "chain-normalize", approved: []bool{false}, reason: "bad_normalize", message: "normalize rejected"} - modules.validators[validator.name] = validator - pipeline := resolvedPipeline() - setResolvedValidatorChain(t, &pipeline, StageNormalize, "alpha", "normalize", resolvedValidatorForTest(validator)) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(output.Rejected) != 1 || output.Rejected[0].Stage != string(StageNormalize) { - t.Fatalf("rejected outputs = %#v, want rejected normalize", output.Rejected) - } - if len(output.NormalizeOutputs) != 0 { - t.Fatalf("NormalizeOutputs = %#v, want none", output.NormalizeOutputs) - } - if len(modules.output.requests[0].NormalizeOutputs) != 0 { - t.Fatalf("output normalize outputs = %#v, want none", modules.output.requests[0].NormalizeOutputs) - } -} - -func TestRunRetriesSameModuleInputAfterFrameworkError(t *testing.T) { - modules := defaultRunnerModules() - modules.extractors["extract-alpha"].failuresBeforeSuccess = 1 - modules.extractors["extract-alpha"].failureErr = errors.New("transient extract failure") - pipeline := resolvedPipeline() - pipeline.ArtifactLanes[0].Extract.Retries = 1 - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - requests := modules.extractors["extract-alpha"].requests - if len(requests) != 3 { - t.Fatalf("extract requests = %d, want retry plus remaining chunk", len(requests)) - } - if requests[0].Chunk.ID != "chunk-0" || requests[1].Chunk.ID != "chunk-0" { - t.Fatalf("retried chunks = %q, %q; want same first chunk input", requests[0].Chunk.ID, requests[1].Chunk.ID) - } - if output.Manifest.ValidationStatus != "approved" { - t.Fatalf("ValidationStatus = %q, want approved", output.Manifest.ValidationStatus) - } -} - -func TestRunRetriesSameModuleInputAfterValidatorRejection(t *testing.T) { - modules := defaultRunnerModules() - validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false, true, true}, reason: "bad_extract", message: "extract rejected"} - modules.validators[validator.name] = validator - pipeline := resolvedPipeline() - setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) - pipeline.ArtifactLanes[0].Extract.Retries = 1 - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - requests := modules.extractors["extract-alpha"].requests - if len(requests) != 3 { - t.Fatalf("extract requests = %d, want retry plus remaining chunk", len(requests)) - } - if requests[0].Chunk.ID != "chunk-0" || requests[1].Chunk.ID != "chunk-0" { - t.Fatalf("retried chunks = %q, %q; want same first chunk input", requests[0].Chunk.ID, requests[1].Chunk.ID) - } - if len(output.Rejected) != 0 { - t.Fatalf("Rejected = %#v, want transient rejection omitted after retry approval", output.Rejected) - } -} - -func TestRunDebugFailedChunkAttemptReferencesScopedLLMOutput(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.callLLM = true - modules.chunker.llmPromptID = "runner.chunk" - modules.chunker.err = errors.New("malformed structured output") - recorder := newMemoryDebugRecorder() - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - llmClient: debugResponseLLMClient{content: []byte(`{"raw":true}`), profileID: "debug-profile"}, - Debug: recorder, - }) - if err == nil || !strings.Contains(err.Error(), "malformed structured output") { - t.Fatalf("Run() error = %v, want chunk failure", err) - } - - attempt := recorder.envelope(t, "chunk/attempt-01.json") - if len(attempt.LLMCalls) != 1 { - t.Fatalf("llm_calls = %#v, want one scoped call", attempt.LLMCalls) - } - call := attempt.LLMCalls[0] - if call.CallID != "0001" || call.PromptPath != "chunk/attempt-01/prompt-0001.json" || call.ResponsePath != "chunk/attempt-01/response-0001.json" || call.ResponseContentPath != "chunk/attempt-01/response-content-0001.json" { - t.Fatalf("llm call reference = %#v, want prompt and response paths", call) - } - if call.PromptID != "runner.chunk" || call.ProfileID != "debug-profile" || call.Model != "debug-model" || call.Error { - t.Fatalf("llm call metadata = %#v, want prompt/profile and no call error", call) - } - - prompt := recorder.envelope(t, call.PromptPath) - promptPayload, ok := prompt.Payload.(debugLLMPromptArtifact) - if !ok { - t.Fatalf("prompt payload type = %T, want debugLLMPromptArtifact", prompt.Payload) - } - if promptPayload.Prompt == nil || len(promptPayload.Prompt.Messages) != 1 || promptPayload.Prompt.Messages[0].Content != "raw prompt text" { - t.Fatalf("prompt payload = %#v, want raw prompt message", promptPayload) - } - - response := recorder.envelope(t, call.ResponsePath) - responsePayload, ok := response.Payload.(debugLLMResponseArtifact) - if !ok { - t.Fatalf("response payload type = %T, want debugLLMResponseArtifact", response.Payload) - } - if responsePayload.ContentPath != call.ResponseContentPath { - t.Fatalf("response content path = %q, want %q", responsePayload.ContentPath, call.ResponseContentPath) - } - if responsePayload.Response == nil || responsePayload.Response.Content != "" { - t.Fatalf("response payload = %#v, want metadata without inline content", responsePayload) - } - if got := string(recorder.bytes[call.ResponseContentPath]); got != "{\n \"raw\": true\n}\n" { - t.Fatalf("response content file = %q, want pretty JSON", got) - } - if _, ok := recorder.payloads["llm/call-0001.json"]; ok { - t.Fatalf("old canonical LLM debug artifact was written") - } - if _, ok := recorder.payloads["chunk/attempt-01/llm-call-0001.json"]; ok { - t.Fatalf("old scoped LLM debug artifact was written") - } -} - -func TestRunDebugWritesNonJSONLLMResponseContentAsText(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.callLLM = true - modules.chunker.llmPromptID = "runner.chunk" - modules.chunker.err = errors.New("malformed structured output") - recorder := newMemoryDebugRecorder() - - _, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - llmClient: debugResponseLLMClient{content: []byte("plain text response"), profileID: "debug-profile"}, - Debug: recorder, - }) - if err == nil || !strings.Contains(err.Error(), "malformed structured output") { - t.Fatalf("Run() error = %v, want chunk failure", err) - } - - attempt := recorder.envelope(t, "chunk/attempt-01.json") - if len(attempt.LLMCalls) != 1 { - t.Fatalf("llm_calls = %#v, want one scoped call", attempt.LLMCalls) - } - call := attempt.LLMCalls[0] - if call.ResponseContentPath != "chunk/attempt-01/response-content-0001.txt" { - t.Fatalf("response content path = %q, want .txt file", call.ResponseContentPath) - } - if got := string(recorder.bytes[call.ResponseContentPath]); got != "plain text response" { - t.Fatalf("response text content = %q, want raw text", got) - } - response := recorder.envelope(t, call.ResponsePath) - responsePayload, ok := response.Payload.(debugLLMResponseArtifact) - if !ok { - t.Fatalf("response payload type = %T, want debugLLMResponseArtifact", response.Payload) - } - if responsePayload.Response == nil || responsePayload.Response.Content != "" { - t.Fatalf("response payload = %#v, want metadata without inline content", responsePayload) - } -} - -func TestRunStopsRetryAfterConfiguredAttemptsAndRecordsAttemptCount(t *testing.T) { - modules := defaultRunnerModules() - validator := &runnerChainValidator{name: "chain-extract", approved: []bool{false}, reason: "bad_extract", message: "extract rejected"} - modules.validators[validator.name] = validator - pipeline := resolvedPipeline() - setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator)) - pipeline.ArtifactLanes[0].Extract.Retries = 1 - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(output.Rejected) != 2 { - t.Fatalf("len(Rejected) = %d, want rejected record per chunk", len(output.Rejected)) - } - if output.Rejected[0].AttemptCount != 2 || output.Rejected[1].AttemptCount != 2 { - t.Fatalf("attempt counts = %#v, want final attempt count 2", output.Rejected) - } - if len(modules.extractors["extract-alpha"].requests) != 4 { - t.Fatalf("extract requests = %d, want two attempts per chunk", len(modules.extractors["extract-alpha"].requests)) - } - if len(output.Manifest.RejectedOutputs) != 2 { - t.Fatalf("manifest rejected outputs = %#v, want rejected records", output.Manifest.RejectedOutputs) - } - if output.Manifest.RejectedOutputs[0].AttemptCount != 2 || output.Manifest.RejectedOutputs[0].ChunkID != "chunk-0" { - t.Fatalf("manifest rejected output = %#v, want final attempt count and chunk provenance", output.Manifest.RejectedOutputs[0]) - } -} - -func TestRunContextCancellationStopsRetries(t *testing.T) { - modules := defaultRunnerModules() - modules.extractors["extract-alpha"].err = errors.New("extract failed") - pipeline := resolvedPipeline() - pipeline.ArtifactLanes[0].Extract.Retries = 2 - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(ctx, RunInput{pipeline: pipeline}) - - if !errors.Is(err, context.Canceled) { - t.Fatalf("Run() error = %v, want context.Canceled", err) - } - if len(modules.extractors["extract-alpha"].requests) != 0 { - t.Fatalf("extract requests = %d, want none after cancellation", len(modules.extractors["extract-alpha"].requests)) - } - if output.Manifest.ValidationStatus != "failed" { - t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) - } -} - -func TestRunRejectsConfiguredValidators(t *testing.T) { - _, err := newPreparedRunner(t, newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{ - pipeline: resolvedPipelineWithValidators("configured", "second-validator"), - }) - assertRunError(t, err, "extract.validators") -} - -func TestRunCollectsStageWarnings(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.warnings = []contracts.Warning{{ReasonCode: "chunk-warning", Message: "chunk warning"}} - modules.extractors["extract-alpha"].warnings = []contracts.Warning{{ReasonCode: "extract-warning", Message: "extract warning"}} - modules.mergers["merge"].warnings = []contracts.Warning{{ReasonCode: "merge-warning", Message: "merge warning"}} - modules.normalizers["normalize"].warnings = []contracts.Warning{{ReasonCode: "normalize-warning", Message: "normalize warning"}} - modules.output.warnings = []contracts.Warning{{ReasonCode: "output-warning", Message: "output warning"}} - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - want := []string{"chunk-warning", "extract-warning", "extract-warning", "merge-warning", "normalize-warning", "output-warning"} - if got := warningReasons(output.Warnings); !reflect.DeepEqual(got, want) { - t.Fatalf("warning reasons = %#v, want %#v", got, want) - } -} - -func TestRunCollectsChunkValidatorWarnings(t *testing.T) { - modules := defaultRunnerModules() - modules.chunker.chunks = []source.Chunk{sourceChunkWithID("chunk-0", 0)} - validator := &runnerChainValidator{ - name: "chain-chunk", - warnings: []contracts.Warning{{ReasonCode: "chunk-validator-warning", Message: "chunk validator warning"}}, - } - modules.validators[validator.name] = validator - pipeline := resolvedPipeline() - setResolvedValidatorChain(t, &pipeline, StageChunk, "", "chunk", resolvedValidatorForTest(validator)) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if got := warningReasons(output.Warnings); !reflect.DeepEqual(got, []string{"chunk-validator-warning"}) { - t.Fatalf("warning reasons = %#v, want chunk validator warning", got) - } -} - -func TestRunOutputEncoderReceivesManifestAndRawOutputs(t *testing.T) { - modules := defaultRunnerModules() - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if len(output.OutputFiles) != 1 { - t.Fatalf("len(OutputFiles) = %d, want 1", len(output.OutputFiles)) - } - file := output.OutputFiles[0] - if file.Name != "outputs/generic.json" { - t.Fatalf("OutputFiles[0].Name = %q, want outputs/generic.json", file.Name) - } - if file.ContentType != "application/json" { - t.Fatalf("ContentType = %q, want application/json", file.ContentType) - } - if string(file.Bytes) != `{"encoded":true}` { - t.Fatalf("OutputFiles[0].Bytes = %s, want encoded payload", file.Bytes) - } - if len(modules.output.requests) != 1 { - t.Fatalf("len(output requests) = %d, want 1", len(modules.output.requests)) - } - req := modules.output.requests[0] - if req.Manifest.PipelineID != "pipeline-1" || req.Manifest.PipelineDigest != "sha256:pipeline" { - t.Fatalf("output manifest = %#v, want pipeline details", req.Manifest) - } - if len(req.NormalizeOutputs) != 1 { - t.Fatalf("len(output NormalizeOutputs) = %d, want 1", len(req.NormalizeOutputs)) - } - if req.NormalizeOutputs[0].LaneID != "alpha" || req.NormalizeOutputs[0].NormalizerKey != "normalize" { - t.Fatalf("NormalizeOutputs[0] = %#v, want normalized alpha output", req.NormalizeOutputs[0]) - } -} - -func TestRunRejectsUnsafeOutputFileNames(t *testing.T) { - tests := []struct { - name string - fileName string - }{ - {name: "empty", fileName: ""}, - {name: "absolute", fileName: "/tmp/output.json"}, - {name: "parent", fileName: "outputs/../manifest.json"}, - {name: "backslash", fileName: `outputs\manifest.json`}, - {name: "unclean", fileName: "outputs//manifest.json"}, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - modules := defaultRunnerModules() - modules.output.files = []contracts.OutputFile{ - {Name: test.fileName, ContentType: "application/json", Bytes: []byte(`{}`)}, - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - - assertRunError(t, err, "output file name") - if output.Manifest.ValidationStatus != "failed" { - t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) - } - }) - } -} - -func TestRunReturnsFailedManifestWhenOutputEncoderFails(t *testing.T) { - modules := defaultRunnerModules() - modules.output.err = errors.New("encode failed") - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - - assertRunError(t, err, "encode failed") - if output.Manifest.ValidationStatus != "failed" { - t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) - } - if output.Manifest.CompletedAt == nil { - t.Fatal("CompletedAt = nil, want failed run completion timestamp") - } - if len(output.NormalizeOutputs) != 1 { - t.Fatalf("len(NormalizeOutputs) = %d, want partial normalized output", len(output.NormalizeOutputs)) - } -} - -func TestRunManifestIncludesPipelineAndLaneDetails(t *testing.T) { - resolved := resolvedPipeline() - resolved.ChunkReferences.ReferenceSet = contracts.ReferenceSet{ - Slots: map[string]contracts.ResolvedReferenceSlot{ - "scene_guide": { - Slot: contracts.ReferenceSlot{Name: "scene_guide"}, - Items: []contracts.ReferenceItem{ - { - SlotName: "scene_guide", - MediaType: "text/plain; charset=utf-8", - Content: []byte("chunk reference content"), - Digest: "sha256:chunk-reference", - Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/scene-guide.txt"}, - SizeBytes: int64(len("chunk reference content")), - BindingSource: contracts.ReferenceBindingSourceCLI, - }, - }, - }, - }, - } - resolved.ArtifactLanes[0].ExtractReferences.ReferenceSet = contracts.ReferenceSet{ - Slots: map[string]contracts.ResolvedReferenceSlot{ - "roster": { - Slot: contracts.ReferenceSlot{Name: "roster"}, - Items: []contracts.ReferenceItem{ - { - SlotName: "roster", - MediaType: "text/plain; charset=utf-8", - Content: []byte("reference content"), - Digest: "sha256:reference", - Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/roster.txt"}, - SizeBytes: int64(len("reference content")), - BindingSource: contracts.ReferenceBindingSourceConfig, - }, - }, - }, - }, - } - resolved.ArtifactLanes[0].MergeReferences.ReferenceSet = contracts.ReferenceSet{ - Slots: map[string]contracts.ResolvedReferenceSlot{ - "merge_notes": { - Slot: contracts.ReferenceSlot{Name: "merge_notes"}, - Items: []contracts.ReferenceItem{ - { - SlotName: "merge_notes", - MediaType: "text/plain; charset=utf-8", - Content: []byte("merge reference content"), - Digest: "sha256:merge-reference", - Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/merge.txt"}, - SizeBytes: int64(len("merge reference content")), - BindingSource: contracts.ReferenceBindingSourceConfig, - }, - }, - }, - }, - } - resolved.ArtifactLanes[0].NormalizeReferences.ReferenceSet = contracts.ReferenceSet{ - Slots: map[string]contracts.ResolvedReferenceSlot{ - "normalization_notes": { - Slot: contracts.ReferenceSlot{Name: "normalization_notes"}, - Items: []contracts.ReferenceItem{ - { - SlotName: "normalization_notes", - MediaType: "text/plain; charset=utf-8", - Content: []byte("normalize reference content"), - Digest: "sha256:normalize-reference", - Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/normalize.txt"}, - SizeBytes: int64(len("normalize reference content")), - BindingSource: contracts.ReferenceBindingSourceConfig, - }, - }, - }, - }, - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: resolved}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - manifest := output.Manifest - if manifest.PipelineID != "pipeline-1" || manifest.PipelineDigest != "sha256:pipeline" { - t.Fatalf("manifest pipeline fields = %#v, want pipeline details", manifest) - } - if manifest.InputModule != "input" || manifest.Chunker != "chunk" || manifest.OutputEncoder != "output" { - t.Fatalf("manifest modules = %#v, want input/chunk/output modules", manifest) - } - if !reflect.DeepEqual(manifest.SourceDigests, []string{"sha256:source"}) { - t.Fatalf("SourceDigests = %#v, want source digest", manifest.SourceDigests) - } - if len(manifest.References) != 4 { - t.Fatalf("References = %#v, want four reference provenance entries", manifest.References) - } - chunkReference := manifest.References[0] - if chunkReference.Stage != string(StageChunk) || chunkReference.LaneID != "" || chunkReference.SlotName != "scene_guide" || chunkReference.Digest != "sha256:chunk-reference" { - t.Fatalf("chunk reference provenance = %#v, want chunk slot digest", chunkReference) - } - if chunkReference.OriginType != "file" || chunkReference.OriginURI != "file:///tmp/scene-guide.txt" || chunkReference.MediaType != "text/plain; charset=utf-8" || chunkReference.SizeBytes != int64(len("chunk reference content")) || chunkReference.BindingSource != contracts.ReferenceBindingSourceCLI { - t.Fatalf("chunk reference provenance = %#v, want origin/media/size/source", chunkReference) - } - extractReference := manifest.References[1] - if extractReference.Stage != string(StageExtract) || extractReference.LaneID != "alpha" || extractReference.SlotName != "roster" || extractReference.Digest != "sha256:reference" { - t.Fatalf("extract reference provenance = %#v, want lane slot digest", extractReference) - } - if extractReference.OriginType != "file" || extractReference.OriginURI != "file:///tmp/roster.txt" || extractReference.MediaType != "text/plain; charset=utf-8" || extractReference.SizeBytes != int64(len("reference content")) || extractReference.BindingSource != contracts.ReferenceBindingSourceConfig { - t.Fatalf("extract reference provenance = %#v, want origin/media/size/source", extractReference) - } - mergeReference := manifest.References[2] - if mergeReference.Stage != string(StageMerge) || mergeReference.LaneID != "alpha" || mergeReference.SlotName != "merge_notes" || mergeReference.Digest != "sha256:merge-reference" { - t.Fatalf("merge reference provenance = %#v, want lane slot digest", mergeReference) - } - normalizeReference := manifest.References[3] - if normalizeReference.Stage != string(StageNormalize) || normalizeReference.LaneID != "alpha" || normalizeReference.SlotName != "normalization_notes" || normalizeReference.Digest != "sha256:normalize-reference" { - t.Fatalf("normalize reference provenance = %#v, want lane slot digest", normalizeReference) - } - if manifest.ValidationStatus != "approved" { - t.Fatalf("ValidationStatus = %q, want approved", manifest.ValidationStatus) - } - if len(manifest.NormalizedOutputs) != 1 { - t.Fatalf("NormalizedOutputs = %#v, want one raw output manifest", manifest.NormalizedOutputs) - } - normalized := manifest.NormalizedOutputs[0] - if normalized.LaneID != "alpha" || normalized.ModuleKey != "normalize" || normalized.MediaType != "application/json" || normalized.Schema.ID != "runner.raw" { - t.Fatalf("normalized output manifest = %#v, want lane/module/media/schema provenance", normalized) - } - if len(manifest.ArtifactLanes) != 1 { - t.Fatalf("len(ArtifactLanes) = %d, want 1", len(manifest.ArtifactLanes)) - } - lane := manifest.ArtifactLanes[0] - if lane.ID != "alpha" || lane.Extractor != "extract-alpha" || lane.Merger != "merge" || lane.Normalizer != "normalize" { - t.Fatalf("ArtifactLanes[0] = %#v, want lane details", lane) - } - if len(manifest.ValidatorChains) != 4 { - t.Fatalf("ValidatorChains = %#v, want four validation points", manifest.ValidatorChains) - } - if manifest.ValidatorChains[0].Stage != string(StageChunk) || manifest.ValidatorChains[0].ModuleKey != "chunk" || len(manifest.ValidatorChains[0].Validators) != 0 { - t.Fatalf("chunk validator chain = %#v, want explicit empty chunk chain", manifest.ValidatorChains[0]) - } -} - -func TestRunManifestIncludesRunTimingAndLLMProfiles(t *testing.T) { - startedAt := time.Now().Add(-time.Minute).UTC() - profiles := []artifacts.LLMProfileManifest{ - {ID: "default", Provider: "scriptorium", Model: "model-a"}, - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - RunID: "run-test", - StartedAt: startedAt, - LLMProfiles: profiles, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - manifest := output.Manifest - if manifest.RunID != "run-test" { - t.Fatalf("RunID = %q, want run-test", manifest.RunID) - } - if manifest.StartedAt == nil || !manifest.StartedAt.Equal(startedAt) { - t.Fatalf("StartedAt = %v, want %s", manifest.StartedAt, startedAt) - } - if manifest.CompletedAt == nil || manifest.CompletedAt.Before(startedAt) { - t.Fatalf("CompletedAt = %v, want timestamp after start", manifest.CompletedAt) - } - if !reflect.DeepEqual(manifest.LLMProfiles, profiles) { - t.Fatalf("LLMProfiles = %#v, want %#v", manifest.LLMProfiles, profiles) - } -} - -func TestRunManifestIncludesProfilesReportedByLLMClient(t *testing.T) { - output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{ - pipeline: resolvedPipeline(), - llmClient: manifestReportingLLMClient{profiles: []artifacts.LLMProfileManifest{ - {ID: "profile-b", Provider: "scriptorium", Model: "model-b"}, - {ID: "profile-a", Provider: "scriptorium", Model: "model-a"}, - {ID: "profile-b", Provider: "scriptorium", Model: "model-b"}, - }}, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - want := []artifacts.LLMProfileManifest{ - {ID: "profile-a", Provider: "scriptorium", Model: "model-a"}, - {ID: "profile-b", Provider: "scriptorium", Model: "model-b"}, - } - if !reflect.DeepEqual(output.Manifest.LLMProfiles, want) { - t.Fatalf("LLMProfiles = %#v, want %#v", output.Manifest.LLMProfiles, want) - } -} - -func TestRunManifestGeneratesRunIDAndTimestamps(t *testing.T) { - output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - if !strings.HasPrefix(output.Manifest.RunID, "run-") { - t.Fatalf("RunID = %q, want generated run ID", output.Manifest.RunID) - } - if output.Manifest.StartedAt == nil { - t.Fatal("StartedAt = nil, want generated timestamp") - } - if output.Manifest.CompletedAt == nil { - t.Fatal("CompletedAt = nil, want generated timestamp") - } -} - -func TestRunManifestIncludesExtractorMetadata(t *testing.T) { - modules := defaultRunnerModules() - modules.extractors["extract-alpha"].manifestMetadata = map[string]any{ - "prompt_id": "test.prompt", - "response_schema_name": "test_schema", - } - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()}) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - - lane := output.Manifest.ArtifactLanes[0] - extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any) - if !ok { - t.Fatalf("lane metadata = %#v, want extractor metadata", lane.Metadata) - } - if extractorMetadata["prompt_id"] != "test.prompt" || extractorMetadata["response_schema_name"] != "test_schema" { - t.Fatalf("extractor metadata = %#v, want prompt and schema metadata", extractorMetadata) - } - if output.Manifest.ModuleMetadata != nil { - if _, ok := output.Manifest.ModuleMetadata["extractor"]; ok { - t.Fatalf("top-level module metadata includes lane metadata key: %#v", output.Manifest.ModuleMetadata) - } - } -} - -func TestRunReturnsPartialOutputWhenLaterLaneFails(t *testing.T) { - modules := defaultRunnerModules() - modules.extractors["extract-beta"] = &runnerExtractor{key: "extract-beta", err: errors.New("extract failed")} - pipeline := resolvedPipeline() - pipeline.ArtifactLanes = append(pipeline.ArtifactLanes, ResolvedArtifactLane{ - ID: "beta", - Extract: Binding("extract-beta"), - Merge: Binding("merge"), - Normalize: Binding("normalize"), - }) - - output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline}) - - assertRunError(t, err, "extract failed") - if output.Manifest.ValidationStatus != "failed" { - t.Fatalf("ValidationStatus = %q, want failed", output.Manifest.ValidationStatus) - } - if len(output.NormalizeOutputs) != 1 { - t.Fatalf("len(NormalizeOutputs) = %d, want first lane output", len(output.NormalizeOutputs)) - } - if len(modules.output.requests) != 0 { - t.Fatalf("output requests = %d, want framework error to abort before output", len(modules.output.requests)) - } -} - -func resolvedPipeline() ResolvedPipeline { - return ResolvedPipeline{ - ID: "pipeline-1", - Digest: "sha256:pipeline", - Input: Binding("input"), - Chunk: Binding("chunk"), - ChunkReferences: referenceTarget(StageChunk, "", "chunk", nil), - ArtifactLanes: []ResolvedArtifactLane{ - { - ID: "alpha", - Extract: Binding("extract-alpha"), - Merge: Binding("merge"), - Normalize: Binding("normalize"), - ExtractReferences: referenceTarget(StageExtract, "alpha", "extract-alpha", nil), - MergeReferences: referenceTarget(StageMerge, "alpha", "merge", nil), - NormalizeReferences: referenceTarget(StageNormalize, "alpha", "normalize", nil), - }, - }, - ValidatorChains: []ResolvedValidatorChain{ - {Stage: StageChunk, ModuleKey: "chunk"}, - {Stage: StageExtract, LaneID: "alpha", ModuleKey: "extract-alpha"}, - {Stage: StageMerge, LaneID: "alpha", ModuleKey: "merge"}, - {Stage: StageNormalize, LaneID: "alpha", ModuleKey: "normalize"}, - }, - Output: Binding("output"), - } -} - -func resolvedPipelineWithValidators(validators ...string) ResolvedPipeline { - pipeline := resolvedPipeline() - for _, validator := range validators { - pipeline.ArtifactLanes[0].Validators = append(pipeline.ArtifactLanes[0].Validators, Binding(validator)) - } - return pipeline -} - -func testReferenceSet(slotName string, content string) contracts.ReferenceSet { - return contracts.ReferenceSet{ - Slots: map[string]contracts.ResolvedReferenceSlot{ - slotName: { - Slot: contracts.ReferenceSlot{Name: slotName}, - Items: []contracts.ReferenceItem{ - { - SlotName: slotName, - MediaType: "text/plain; charset=utf-8", - Content: []byte(content), - Digest: "sha256:test", - Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///tmp/reference.txt"}, - SizeBytes: int64(len(content)), - BindingSource: contracts.ReferenceBindingSourceConfig, - }, - }, - }, - }, - } -} - -type runnerModules struct { - input *runnerInputAdapter - chunker *runnerChunker - extractors map[string]*runnerExtractor - mergers map[string]*runnerMerger - normalizers map[string]*runnerNormalizer - validators map[string]contracts.LegacyRawValidator - output *runnerOutputEncoder - inputBuildErr error - chunkerBuildErr error -} - -func defaultRunnerModules() *runnerModules { - return &runnerModules{ - input: &runnerInputAdapter{key: "input", doc: validSourceDocument()}, - chunker: &runnerChunker{key: "chunk", chunks: []source.Chunk{sourceChunkWithID("chunk-0", 0), sourceChunkWithID("chunk-1", 1)}}, - extractors: map[string]*runnerExtractor{ - "extract-alpha": {key: "extract-alpha"}, - }, - mergers: map[string]*runnerMerger{ - "merge": {key: "merge"}, - }, - normalizers: map[string]*runnerNormalizer{ - "normalize": {key: "normalize"}, - }, - validators: map[string]contracts.LegacyRawValidator{ - "configured": &runnerValidator{name: "configured"}, - "second-validator": &runnerValidator{name: "second-validator"}, - }, - output: &runnerOutputEncoder{ - key: "output", - files: []contracts.OutputFile{ - {Name: "outputs/generic.json", ContentType: "application/json", Bytes: []byte(`{"encoded":true}`)}, - }, - }, - } -} - -func newRunnerRegistries(t *testing.T, modules *runnerModules) Registries { - t.Helper() - if modules == nil { - modules = defaultRunnerModules() - } - - registries := Registries{ - Inputs: NewInputAdapterRegistry(), - Chunkers: NewChunkerRegistry(), - ArtifactCodecs: NewArtifactCodecRegistry(), - Extractors: NewExtractorRegistry(), - Mergers: NewMergerRegistry(), - Normalizers: NewNormalizerRegistry(), - Validators: NewValidatorRegistry(), - ValidatorChains: NewValidatorChainRegistry(), - Outputs: NewOutputEncoderRegistry(), - } - allowOptions := func(map[string]any) error { return nil } - if err := registries.Inputs.RegisterBuilderWithSpec(defaultModuleSpec("input", StageInput), allowOptions, func(request BuildRequest) (contracts.InputAdapter, error) { - if modules.inputBuildErr != nil { - return nil, modules.inputBuildErr - } - modules.input.buildOptions = cloneOptions(request.Options) - return modules.input, nil - }); err != nil { - t.Fatalf("register input: %v", err) - } - if err := registries.Chunkers.RegisterBuilderWithSpec(defaultModuleSpec("chunk", StageChunk), allowOptions, func(request BuildRequest) (contracts.Chunker, error) { - if modules.chunkerBuildErr != nil { - return nil, modules.chunkerBuildErr - } - modules.chunker.buildOptions = cloneOptions(request.Options) - modules.chunker.llmClient = request.Dependencies.LLM - return modules.chunker, nil - }); err != nil { - t.Fatalf("register chunker: %v", err) - } - for key, extractor := range modules.extractors { - extractor := extractor - if err := registries.Extractors.RegisterLegacyRaw(key, func() (contracts.LegacyRawExtractor, error) { return extractor, nil }); err != nil { - t.Fatalf("register extractor %q: %v", key, err) - } - } - for key, merger := range modules.mergers { - merger := merger - if err := registries.Mergers.RegisterLegacyRaw(key, func() (contracts.LegacyRawMerger, error) { return merger, nil }); err != nil { - t.Fatalf("register merger %q: %v", key, err) - } - } - for key, normalizer := range modules.normalizers { - normalizer := normalizer - if err := registries.Normalizers.RegisterLegacyRaw(key, func() (contracts.LegacyRawNormalizer, error) { return normalizer, nil }); err != nil { - t.Fatalf("register normalizer %q: %v", key, err) - } - } - for key, validator := range modules.validators { - validator := validator - spec := ValidatorSpec{Key: key, ExecutionClass: validator.ExecutionClass()} - if err := registries.Validators.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawValidator, error) { return validator, nil }); err != nil { - t.Fatalf("register validator %q: %v", key, err) - } - } - if err := registries.Outputs.RegisterBuilderWithSpec(defaultModuleSpec("output", StageOutput), allowOptions, func(request BuildRequest) (contracts.OutputEncoder, error) { - modules.output.buildOptions = cloneOptions(request.Options) - return modules.output, nil - }); err != nil { - t.Fatalf("register output: %v", err) - } - return registries -} - -type runnerInputAdapter struct { - key string - doc *source.SourceDocument - err error - manifestMetadata map[string]any - requests []contracts.ParseRequest - buildOptions map[string]any -} - -func (adapter *runnerInputAdapter) Key() string { - return adapter.key -} - -func (adapter *runnerInputAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { - adapter.requests = append(adapter.requests, req) - return adapter.doc, adapter.err -} - -func (adapter *runnerInputAdapter) ManifestMetadata() map[string]any { - return adapter.manifestMetadata -} - -type runnerChunker struct { - key string - chunks []source.Chunk - warnings []contracts.Warning - err error - failureErr error - failuresBeforeSuccess int - callLLM bool - llmPromptID string - manifestMetadata map[string]any - requests []contracts.ChunkRequest - buildOptions map[string]any - llmClient contracts.StructuredLLMClient -} - -func (chunker *runnerChunker) Key() string { - return chunker.key -} - -func (chunker *runnerChunker) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (chunker *runnerChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) { - chunker.requests = append(chunker.requests, req) - if chunker.failuresBeforeSuccess > 0 { - chunker.failuresBeforeSuccess-- - err := chunker.failureErr - if err == nil { - err = errors.New("transient chunk failure") - } - return contracts.ChunkResult{}, err - } - if chunker.callLLM && chunker.llmClient != nil { - promptID := strings.TrimSpace(chunker.llmPromptID) - if promptID == "" { - promptID = "runner.chunk" - } - var out map[string]any - if _, err := chunker.llmClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ - StageName: chunker.key, - PromptID: promptID, - ProfileID: req.LLMProfile, - }, &out); err != nil { - return contracts.ChunkResult{}, err - } - } - return contracts.ChunkResult{ - Chunks: chunker.chunks, - Warnings: chunker.warnings, - }, chunker.err -} - -func (chunker *runnerChunker) ManifestMetadata() map[string]any { - return chunker.manifestMetadata -} - -type runnerExtractor struct { - key string - manifestMetadata map[string]any - output *contracts.ExtractOutput - warnings []contracts.Warning - err error - failureErr error - failuresBeforeSuccess int - requests []contracts.ExtractionRequest - seenChunkIDs []string - seenLLMClients []contracts.StructuredLLMClient - seenMetadata []map[string]any -} - -func (extractor *runnerExtractor) Key() string { - return extractor.key -} - -func (extractor *runnerExtractor) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (extractor *runnerExtractor) ManifestMetadata() map[string]any { - return extractor.manifestMetadata -} - -func (extractor *runnerExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - extractor.requests = append(extractor.requests, req) - if req.Chunk != nil { - extractor.seenChunkIDs = append(extractor.seenChunkIDs, req.Chunk.ID) - } - extractor.seenLLMClients = append(extractor.seenLLMClients, req.LLMClient) - extractor.seenMetadata = append(extractor.seenMetadata, req.Metadata) - - if extractor.failuresBeforeSuccess > 0 { - extractor.failuresBeforeSuccess-- - err := extractor.failureErr - if err == nil { - err = errors.New("transient extract failure") - } - return contracts.ExtractionResult{}, err - } - - output := contracts.ExtractOutput{ - Schema: contracts.ResponseSchema{ID: "runner.raw", Name: "runner_raw", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(`{"value":true}`), - MediaType: "application/json", - }, - } - if req.Chunk != nil { - output.Payload.Content = []byte(`{"chunk":"` + req.Chunk.ID + `"}`) - } - if extractor.output != nil { - output = *extractor.output - } - return contracts.ExtractionResult{ - Output: output, - Warnings: extractor.warnings, - }, extractor.err -} - -type runnerMerger struct { - key string - result *contracts.MergeOutput - warnings []contracts.Warning - err error - failureErr error - failuresBeforeSuccess int - requests []contracts.MergeRequest -} - -func (merger *runnerMerger) Key() string { - return merger.key -} - -func (merger *runnerMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { - merger.requests = append(merger.requests, req) - if merger.failuresBeforeSuccess > 0 { - merger.failuresBeforeSuccess-- - err := merger.failureErr - if err == nil { - err = errors.New("transient merge failure") - } - return contracts.MergeResult{}, err - } - output := contracts.MergeOutput{ - LaneID: req.LaneID, - SourceID: req.Source.ID, - Schema: contracts.ResponseSchema{ID: "runner.raw", Name: "runner_raw", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(`{"merged":true}`), - MediaType: "application/json", - }, - } - if merger.result != nil { - output = *merger.result - } - return contracts.MergeResult{ - Output: output, - Warnings: merger.warnings, - }, merger.err -} - -type runnerNormalizer struct { - key string - result *contracts.NormalizeOutput - warnings []contracts.Warning - err error - failureErr error - failuresBeforeSuccess int - requests []contracts.NormalizeRequest -} - -func (normalizer *runnerNormalizer) Key() string { - return normalizer.key -} - -func (normalizer *runnerNormalizer) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (normalizer *runnerNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { - normalizer.requests = append(normalizer.requests, req) - if normalizer.failuresBeforeSuccess > 0 { - normalizer.failuresBeforeSuccess-- - err := normalizer.failureErr - if err == nil { - err = errors.New("transient normalize failure") - } - return contracts.NormalizeResult{}, err - } - output := contracts.NormalizeOutput{ - LaneID: req.LaneID, - SourceID: req.MergeOutput.SourceID, - Schema: req.MergeOutput.Schema, - Payload: req.MergeOutput.Payload, - } - if normalizer.result != nil { - output = *normalizer.result - } - return contracts.NormalizeResult{ - Output: output, - Warnings: normalizer.warnings, - }, normalizer.err -} - -type runnerValidator struct { - name string - executionClass contracts.ExecutionClass - approved []bool - reason string - message string - warnings []contracts.Warning - err error - order *[]string - calls int - requests []contracts.ValidationRequest -} - -type runnerChainValidator struct { - name string - executionClass contracts.ExecutionClass - approved []bool - reason string - message string - warnings []contracts.Warning - err error - calls int - requests []contracts.ValidationRequest -} - -func (validator *runnerChainValidator) Name() string { - return validator.name -} - -func (validator *runnerChainValidator) ExecutionClass() contracts.ExecutionClass { - if validator.executionClass != "" { - return validator.executionClass - } - return contracts.ExecutionClassDeterministic -} - -func (validator *runnerChainValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - validator.calls++ - validator.requests = append(validator.requests, req) - if validator.err != nil { - return contracts.ValidationResult{}, validator.err - } - approved := true - if len(validator.approved) > 0 { - index := validator.calls - 1 - if index >= len(validator.approved) { - index = len(validator.approved) - 1 - } - approved = validator.approved[index] - } - return contracts.ValidationResult{ - Approved: approved, - ReasonCode: validator.reason, - Message: validator.message, - Warnings: validator.warnings, - }, nil -} - -func (validator *runnerValidator) Name() string { - return validator.name -} - -func (validator *runnerValidator) ExecutionClass() contracts.ExecutionClass { - if validator.executionClass != "" { - return validator.executionClass - } - return contracts.ExecutionClassDeterministic -} - -func (validator *runnerValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - validator.calls++ - validator.requests = append(validator.requests, req) - if validator.order != nil { - *validator.order = append(*validator.order, validator.name) - } - approved := true - if len(validator.approved) > 0 { - index := validator.calls - 1 - if index >= len(validator.approved) { - index = len(validator.approved) - 1 - } - approved = validator.approved[index] - } - return contracts.ValidationResult{ - Approved: approved, - ReasonCode: validator.reason, - Message: validator.message, - Warnings: validator.warnings, - }, validator.err -} - -type runnerOutputEncoder struct { - key string - files []contracts.OutputFile - warnings []contracts.Warning - err error - manifestMetadata map[string]any - requests []contracts.OutputRequest - buildOptions map[string]any -} - -func (encoder *runnerOutputEncoder) Key() string { - return encoder.key -} - -func (encoder *runnerOutputEncoder) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { - encoder.requests = append(encoder.requests, req) - return contracts.OutputResult{ - Files: encoder.files, - Warnings: encoder.warnings, - }, encoder.err -} - -func (encoder *runnerOutputEncoder) ManifestMetadata() map[string]any { - return encoder.manifestMetadata -} - -type runnerCheckpointLoader struct { - source SourceCheckpoint - chunk ChunkCheckpoint - extract ExtractCheckpoint - merge MergeCheckpoint - normalize NormalizeCheckpoint - reuse map[string]bool -} - -func (loader *runnerCheckpointLoader) Enabled() bool { - return true -} - -func (loader *runnerCheckpointLoader) Source(string) (SourceCheckpoint, CheckpointDecision) { - if loader.reuse["source"] { - return loader.source, CheckpointDecision{Reused: true, Reason: "test checkpoint"} - } - return SourceCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"} -} - -func (loader *runnerCheckpointLoader) Chunk(string, string) (ChunkCheckpoint, CheckpointDecision) { - if loader.reuse["chunk"] { - return loader.chunk, CheckpointDecision{Reused: true, Reason: "test checkpoint"} - } - return ChunkCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"} -} - -func (loader *runnerCheckpointLoader) Extract(string, string, []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) { - if loader.reuse["extract"] { - return loader.extract, CheckpointDecision{Reused: true, Reason: "test checkpoint"} - } - return ExtractCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"} -} - -func (loader *runnerCheckpointLoader) Merge(string, string, []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) { - if loader.reuse["merge"] { - return loader.merge, CheckpointDecision{Reused: true, Reason: "test checkpoint"} - } - return MergeCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"} -} - -func (loader *runnerCheckpointLoader) Normalize(string, string, []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) { - if loader.reuse["normalize"] { - return loader.normalize, CheckpointDecision{Reused: true, Reason: "test checkpoint"} - } - return NormalizeCheckpoint{}, CheckpointDecision{Reason: "test checkpoint missing"} -} - -type fakeLLMClient struct{} - -func (client fakeLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { - return contracts.StructuredCompletionResponse{}, nil -} - -type debugResponseLLMClient struct { - content []byte - profileID string - err error -} - -func (client debugResponseLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { - profileID := client.profileID - if profileID == "" { - profileID = req.ProfileID - } - return contracts.StructuredCompletionResponse{ - Content: append([]byte(nil), client.content...), - Model: "debug-model", - ProfileID: profileID, - Debug: &contracts.LLMDebugMaterial{ - Prompt: &contracts.LLMDebugPrompt{ - PromptID: req.PromptID, - SelectedProfileID: profileID, - Messages: []contracts.LLMDebugMessage{ - {Role: "user", Content: "raw prompt text"}, - }, - }, - Response: &contracts.LLMDebugResponse{ - Content: string(client.content), - SelectedProfileID: profileID, - ModelName: "debug-model", - }, - }, - }, client.err -} - -type memoryDebugRecorder struct { - payloads map[string]any - bytes map[string][]byte -} - -func newMemoryDebugRecorder() *memoryDebugRecorder { - return &memoryDebugRecorder{ - payloads: map[string]any{}, - bytes: map[string][]byte{}, - } -} - -func (recorder *memoryDebugRecorder) Enabled() bool { return true } - -func (recorder *memoryDebugRecorder) WriteJSON(name string, payload any) error { - recorder.payloads[name] = payload - return nil -} - -func (recorder *memoryDebugRecorder) WriteBytes(name string, data []byte) error { - recorder.bytes[name] = append([]byte(nil), data...) - return nil -} - -func (recorder *memoryDebugRecorder) envelope(t *testing.T, name string) debugTimedEnvelope { - t.Helper() - payload, ok := recorder.payloads[name] - if !ok { - t.Fatalf("debug artifact %q not written; got %#v", name, recorder.payloads) - } - envelope, ok := payload.(debugTimedEnvelope) - if !ok { - t.Fatalf("debug artifact %q type = %T, want debugTimedEnvelope", name, payload) - } - return envelope -} - -type manifestReportingLLMClient struct { - fakeLLMClient - profiles []artifacts.LLMProfileManifest -} - -func (client manifestReportingLLMClient) LLMProfileManifests() []artifacts.LLMProfileManifest { - return append([]artifacts.LLMProfileManifest(nil), client.profiles...) -} - -func validSourceDocument() *source.SourceDocument { - return &source.SourceDocument{ - ID: "source-1", - Kind: "document", - Format: "text/plain", - Digest: "sha256:source", - Units: []source.SourceUnit{ - {ID: 1, Kind: "unit", Text: "Source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}}, - {ID: 2, Kind: "unit", Text: "Second source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 2, EndUnitID: 2}}, - {ID: 3, Kind: "unit", Text: "Third source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 3, EndUnitID: 3}}, - }, - } -} - -func sourceDocumentWithUnitMetadata() *source.SourceDocument { - return &source.SourceDocument{ - ID: "source-1", - Kind: "document", - Format: "text/plain", - Digest: "sha256:source", - Units: []source.SourceUnit{ - { - ID: 1, - Kind: "source-kind", - Text: "source text", - Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}, - Metadata: map[string]any{ - "speaker": "source-speaker", - "topic": "source-topic", - }, - }, - { - ID: 2, - Kind: "source-kind", - Text: "second source text", - Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 2, EndUnitID: 2}, - Metadata: map[string]any{ - "speaker": "source-speaker-2", - }, - }, - }, - } -} - -func sourceChunkWithID(id string, index int) source.Chunk { - unit := unitWithID("u1") - return source.Chunk{ - ID: id, - SourceID: "source-1", - Index: index, - Ref: unit.Ref, - Content: []byte(`{"units":[{"id":1,"kind":"unit","text":"Source unit."}]}`), - MediaType: "application/json", - Units: []source.SourceUnit{unit}, - } -} - -func sourceChunkWithContent(id string, index int, content []byte, mediaType string) source.Chunk { - chunk := sourceChunkWithID(id, index) - chunk.Content = append([]byte(nil), content...) - chunk.MediaType = mediaType - return chunk -} - -func unitWithID(id string) source.SourceUnit { - switch id { - case "u1": - return source.SourceUnit{ID: 1, Kind: "unit", Text: "Source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 1, EndUnitID: 1}} - case "u2": - return source.SourceUnit{ID: 2, Kind: "unit", Text: "Second source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 2, EndUnitID: 2}} - case "u3": - return source.SourceUnit{ID: 3, Kind: "unit", Text: "Third source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 3, EndUnitID: 3}} - case "u9": - return source.SourceUnit{ID: 9, Kind: "unit", Text: "Unknown source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 9, EndUnitID: 9}} - default: - return source.SourceUnit{ID: 99, Kind: "unit", Text: "Unknown source unit.", Ref: source.SourceRef{SourceID: "source-1", StartUnitID: 99, EndUnitID: 99}} - } -} - -func chunkWithUnits(id string, sourceID string, index int, units ...source.SourceUnit) source.Chunk { - startUnitID, endUnitID := 1, 1 - if len(units) > 0 { - startUnitID = units[0].ID - endUnitID = units[len(units)-1].ID - } - return chunkWithRef(id, sourceID, index, startUnitID, endUnitID, units...) -} - -func chunkWithRef(id string, sourceID string, index int, startUnitID int, endUnitID int, units ...source.SourceUnit) source.Chunk { - return source.Chunk{ - ID: id, - SourceID: sourceID, - Index: index, - Ref: source.SourceRef{SourceID: sourceID, StartUnitID: startUnitID, EndUnitID: endUnitID}, - Content: []byte(`{"units":[1]}`), - MediaType: "application/json", - Units: append([]source.SourceUnit(nil), units...), - } -} - -func warningReasons(warnings []contracts.Warning) []string { - reasons := make([]string, 0, len(warnings)) - for _, warning := range warnings { - reasons = append(reasons, warning.ReasonCode) - } - return reasons -} - -func assertNoCheckpointMetadata(t *testing.T, metadata map[string]any) { - t.Helper() - - for key, value := range metadata { - lowerKey := strings.ToLower(key) - if strings.Contains(lowerKey, "checkpoint") || strings.Contains(lowerKey, "workspace") { - t.Fatalf("metadata key %q exposes checkpoint/workspace state", key) - } - text, ok := value.(string) - if !ok { - continue - } - lowerValue := strings.ToLower(text) - if strings.Contains(lowerValue, "checkpoint") || strings.Contains(lowerValue, "workspace") { - t.Fatalf("metadata value for %q exposes checkpoint/workspace state: %q", key, text) - } - } -} - -func assertRunError(t *testing.T, err error, want string) { - t.Helper() - - if err == nil { - t.Fatal("Run() error = nil, want error") - } - if !strings.Contains(err.Error(), want) { - t.Fatalf("Run() error = %q, want substring %q", err.Error(), want) - } -} - -func resolvedValidatorForTest(validator contracts.LegacyRawValidator) ResolvedValidator { - return ResolvedValidator{ - Binding: Binding(validator.Name()), - ExecutionClass: validator.ExecutionClass(), - } -} - -func setResolvedValidatorChain(t *testing.T, resolved *ResolvedPipeline, stage ModuleStage, laneID string, module string, validators ...ResolvedValidator) { - t.Helper() - - if resolved == nil { - t.Fatal("resolved pipeline must not be nil") - } - chain := ResolvedValidatorChain{ - Stage: stage, - LaneID: laneID, - ModuleKey: module, - Validators: append([]ResolvedValidator(nil), validators...), - } - for index := range resolved.ValidatorChains { - existing := resolved.ValidatorChains[index] - if existing.Stage == stage && existing.LaneID == laneID && existing.ModuleKey == module { - resolved.ValidatorChains[index] = chain - return - } - } - resolved.ValidatorChains = append(resolved.ValidatorChains, chain) -} diff --git a/internal/framework/pipeline/runner_typed.go b/internal/framework/pipeline/runner_typed.go index 1b521de..09de21d 100644 --- a/internal/framework/pipeline/runner_typed.go +++ b/internal/framework/pipeline/runner_typed.go @@ -13,54 +13,30 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) -func loadArtifactExtract(loader CheckpointLoader, laneID, moduleKey string, deps []CheckpointFingerprint) (ArtifactExtractCheckpoint, CheckpointDecision) { - typed, ok := loader.(ArtifactCheckpointLoader) - if !ok { - return ArtifactExtractCheckpoint{}, CheckpointDecision{Reason: "artifact checkpoint loading is unavailable"} - } - return typed.ArtifactExtract(laneID, moduleKey, deps) +func loadExtract(loader CheckpointLoader, laneID, moduleKey string, deps []CheckpointFingerprint) (ExtractCheckpoint, CheckpointDecision) { + return loader.Extract(laneID, moduleKey, deps) } -func loadArtifactMerge(loader CheckpointLoader, laneID, moduleKey string, deps []CheckpointFingerprint) (ArtifactMergeCheckpoint, CheckpointDecision) { - typed, ok := loader.(ArtifactCheckpointLoader) - if !ok { - return ArtifactMergeCheckpoint{}, CheckpointDecision{Reason: "artifact checkpoint loading is unavailable"} - } - return typed.ArtifactMerge(laneID, moduleKey, deps) +func loadMerge(loader CheckpointLoader, laneID, moduleKey string, deps []CheckpointFingerprint) (MergeCheckpoint, CheckpointDecision) { + return loader.Merge(laneID, moduleKey, deps) } -func loadArtifactNormalize(loader CheckpointLoader, laneID, moduleKey string, deps []CheckpointFingerprint) (ArtifactNormalizeCheckpoint, CheckpointDecision) { - typed, ok := loader.(ArtifactCheckpointLoader) - if !ok { - return ArtifactNormalizeCheckpoint{}, CheckpointDecision{Reason: "artifact checkpoint loading is unavailable"} - } - return typed.ArtifactNormalize(laneID, moduleKey, deps) +func loadNormalize(loader CheckpointLoader, laneID, moduleKey string, deps []CheckpointFingerprint) (NormalizeCheckpoint, CheckpointDecision) { + return loader.Normalize(laneID, moduleKey, deps) } -func recordArtifactExtract(recorder CheckpointRecorder, laneID, moduleKey string, deps []CheckpointFingerprint, outputs []ArtifactCheckpointOutput, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { - typed, ok := recorder.(ArtifactCheckpointRecorder) - if !ok { - return nil - } - return typed.ArtifactExtractSucceeded(laneID, moduleKey, deps, outputs, rejected, warnings) +func recordExtract(recorder CheckpointRecorder, laneID, moduleKey string, deps []CheckpointFingerprint, outputs []CheckpointArtifact, rejected []contracts.RejectedOutput, warnings []contracts.Warning) error { + return recorder.ExtractSucceeded(laneID, moduleKey, deps, outputs, rejected, warnings) } -func recordArtifactMerge(recorder CheckpointRecorder, laneID, moduleKey string, deps []CheckpointFingerprint, output ArtifactCheckpointOutput, warnings []contracts.Warning) error { - typed, ok := recorder.(ArtifactCheckpointRecorder) - if !ok { - return nil - } - return typed.ArtifactMergeSucceeded(laneID, moduleKey, deps, output, warnings) +func recordMerge(recorder CheckpointRecorder, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { + return recorder.MergeSucceeded(laneID, moduleKey, deps, output, warnings) } -func recordArtifactNormalize(recorder CheckpointRecorder, laneID, moduleKey string, deps []CheckpointFingerprint, output ArtifactCheckpointOutput, warnings []contracts.Warning) error { - typed, ok := recorder.(ArtifactCheckpointRecorder) - if !ok { - return nil - } - return typed.ArtifactNormalizeSucceeded(laneID, moduleKey, deps, output, warnings) +func recordNormalize(recorder CheckpointRecorder, laneID, moduleKey string, deps []CheckpointFingerprint, output CheckpointArtifact, warnings []contracts.Warning) error { + return recorder.NormalizeSucceeded(laneID, moduleKey, deps, output, warnings) } -func cloneArtifactCheckpointOutput(output ArtifactCheckpointOutput) ArtifactCheckpointOutput { +func cloneCheckpointArtifact(output CheckpointArtifact) CheckpointArtifact { output.Artifact = contracts.CloneSerializedArtifact(output.Artifact) return output } -func hydrateCheckpointArtifact(codec artifactCodecEntry, output ArtifactCheckpointOutput, value any) ArtifactCheckpointOutput { +func hydrateCheckpointArtifact(codec artifactCodecEntry, output CheckpointArtifact, value any) CheckpointArtifact { output.Artifact.Schema = contracts.CloneArtifactSchema(codec.spec.Schema) if codec.metadata != nil { output.Artifact.Metadata = cloneMetadata(codec.metadata(value)) @@ -69,7 +45,7 @@ func hydrateCheckpointArtifact(codec artifactCodecEntry, output ArtifactCheckpoi } return output } -func artifactCheckpointDigests(outputs []ArtifactCheckpointOutput) []CheckpointFingerprint { +func artifactCheckpointDigests(outputs []CheckpointArtifact) []CheckpointFingerprint { values := make([]CheckpointFingerprint, 0, len(outputs)) for i, output := range outputs { sum := sha256.Sum256(output.Artifact.Content) @@ -77,7 +53,7 @@ func artifactCheckpointDigests(outputs []ArtifactCheckpointOutput) []CheckpointF } return normalizeCheckpointFingerprints(values) } -func debugArtifactCheckpointOutput(output ArtifactCheckpointOutput) map[string]any { +func debugCheckpointArtifact(output CheckpointArtifact) map[string]any { artifact := output.Artifact schema := contracts.CloneArtifactSchema(artifact.Schema) digest := output.SchemaDigest @@ -89,13 +65,13 @@ func debugArtifactCheckpointOutput(output ArtifactCheckpointOutput) map[string]a content.ContentDigest = debugContentDigest(artifact.Content) return map[string]any{"lane_id": output.LaneID, "module_key": output.ModuleKey, "source_id": output.SourceID, "chunk_id": output.ChunkID, "chunk_index": output.ChunkIndex, "chunk_ref": output.ChunkRef, "artifact_kind": artifact.Kind, "schema": schema, "schema_digest": digest, "content": content} } -func debugArtifactCheckpointOutputs(outputs []ArtifactCheckpointOutput) []map[string]any { +func debugCheckpointArtifacts(outputs []CheckpointArtifact) []map[string]any { if len(outputs) == 0 { return nil } out := make([]map[string]any, 0, len(outputs)) for _, output := range outputs { - out = append(out, debugArtifactCheckpointOutput(output)) + out = append(out, debugCheckpointArtifact(output)) } return out } @@ -117,7 +93,7 @@ func serializeArtifact(codec artifactCodecEntry, value any, candidate bool) (con return contracts.SerializedArtifact{Kind: codec.spec.Kind, Schema: contracts.CloneArtifactSchema(schema), MediaType: codec.spec.MediaType, Content: append([]byte(nil), content...), Metadata: cloneMetadata(metadata)}, nil } -func decodeCheckpointArtifact(codec artifactCodecEntry, artifact ArtifactCheckpointOutput) (any, error) { +func decodeCheckpointArtifact(codec artifactCodecEntry, artifact CheckpointArtifact) (any, error) { expectedDigest := contracts.DigestArtifactSchema(codec.spec.Schema) if artifact.Artifact.Kind != codec.spec.Kind { return nil, fmt.Errorf("artifact kind %q does not match codec %q", artifact.Artifact.Kind, codec.spec.Kind) @@ -134,12 +110,12 @@ func decodeCheckpointArtifact(codec artifactCodecEntry, artifact ArtifactCheckpo return codec.decode(append([]byte(nil), artifact.Artifact.Content...)) } -func checkpointArtifact(codec artifactCodecEntry, laneID, moduleKey, sourceID string, value any) (ArtifactCheckpointOutput, error) { +func checkpointArtifact(codec artifactCodecEntry, laneID, moduleKey, sourceID string, value any) (CheckpointArtifact, error) { serialized, err := serializeArtifact(codec, value, false) if err != nil { - return ArtifactCheckpointOutput{}, err + return CheckpointArtifact{}, err } - return ArtifactCheckpointOutput{LaneID: laneID, ModuleKey: moduleKey, SourceID: sourceID, Artifact: serialized, SchemaDigest: contracts.DigestArtifactSchema(serialized.Schema)}, nil + return CheckpointArtifact{LaneID: laneID, ModuleKey: moduleKey, SourceID: sourceID, Artifact: serialized, SchemaDigest: contracts.DigestArtifactSchema(serialized.Schema)}, nil } func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints CheckpointRecorder, loader CheckpointLoader, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []source.Chunk, prepared preparedLaneExecutor, output *RunOutput) error { @@ -150,7 +126,7 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C setTypedLaneManifestMetadata(output, lane.ID, typed.extractor, typed.merger, typed.normalizer) values := make([]erasedExtractArtifact, 0, len(chunks)) - serializedExtracts := make([]ArtifactCheckpointOutput, 0, len(chunks)) + serializedExtracts := make([]CheckpointArtifact, 0, len(chunks)) extractWarnings := []contracts.Warning{} rejectedStart := len(output.Rejected) chunksDigest, err := joinedChunkDigest(chunks) @@ -158,7 +134,7 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C return fmt.Errorf("digest chunks for lane %q: %w", lane.ID, err) } extractDeps := digestFingerprints("chunks", chunksDigest) - cp, decision := loadArtifactExtract(loader, lane.ID, lane.Extract.Module, extractDeps) + cp, decision := loadExtract(loader, lane.ID, lane.Extract.Module, extractDeps) if decision.Reused { for _, stored := range cp.Outputs { if _, decodeErr := decodeCheckpointArtifact(typed.codec, stored); decodeErr != nil { @@ -183,7 +159,7 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C artifact.ChunkRef = chunks[stored.ChunkIndex].Ref } values = append(values, artifact) - serializedExtracts = append(serializedExtracts, cloneArtifactCheckpointOutput(stored)) + serializedExtracts = append(serializedExtracts, cloneCheckpointArtifact(stored)) } extractWarnings = cloneWarnings(cp.Warnings) output.Warnings = append(output.Warnings, extractWarnings...) @@ -195,7 +171,7 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C for i := range chunks { chunk := chunks[i] var accepted erasedExtractArtifact - var serializedAccepted ArtifactCheckpointOutput + var serializedAccepted CheckpointArtifact var acceptedWarnings []contracts.Warning ok, rejection, runErr := runWithRetry(ctx, lane.Extract.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) { started := time.Now().UTC() @@ -218,7 +194,7 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C stored.ChunkID, stored.ChunkIndex, stored.ChunkRef = artifact.ChunkID, artifact.ChunkIndex, artifact.ChunkRef accepted, serializedAccepted = artifact, stored acceptedWarnings = append(cloneWarnings(result.Warnings), warnings...) - if debugErr := writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{Stage: string(StageExtract), LaneID: lane.ID, ModuleKey: lane.Extract.Module, Attempt: attempt, StartedAt: started, Payload: map[string]any{"output": debugArtifactCheckpointOutput(stored), "warnings": debugWarningEnvelopes(acceptedWarnings)}}, llmScope)); debugErr != nil { + if debugErr := writeDebugTimed(input.Debug, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{Stage: string(StageExtract), LaneID: lane.ID, ModuleKey: lane.Extract.Module, Attempt: attempt, StartedAt: started, Payload: map[string]any{"output": debugCheckpointArtifact(stored), "warnings": debugWarningEnvelopes(acceptedWarnings)}}, llmScope)); debugErr != nil { return false, nil, debugErr } return true, nil, nil @@ -236,13 +212,13 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C extractWarnings = append(extractWarnings, acceptedWarnings...) output.Warnings = append(output.Warnings, acceptedWarnings...) } - if err := recordArtifactExtract(checkpoints, lane.ID, lane.Extract.Module, extractDeps, serializedExtracts, cloneRejectedOutputs(output.Rejected[rejectedStart:]), extractWarnings); err != nil { + if err := recordExtract(checkpoints, lane.ID, lane.Extract.Module, extractDeps, serializedExtracts, cloneRejectedOutputs(output.Rejected[rejectedStart:]), extractWarnings); err != nil { return fmt.Errorf("write extract checkpoint for lane %q: %w", lane.ID, err) } } sort.SliceStable(values, func(i, j int) bool { return values[i].ChunkIndex < values[j].ChunkIndex }) sort.SliceStable(serializedExtracts, func(i, j int) bool { return serializedExtracts[i].ChunkIndex < serializedExtracts[j].ChunkIndex }) - if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": decision.Reused, "outputs": debugArtifactCheckpointOutputs(serializedExtracts), "rejected": debugRejectedOutputEnvelopes(output.Rejected[rejectedStart:]), "warnings": debugWarningEnvelopes(extractWarnings)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("extract", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageExtract), LaneID: lane.ID, ModuleKey: lane.Extract.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": decision.Reused, "outputs": debugCheckpointArtifacts(serializedExtracts), "rejected": debugRejectedOutputEnvelopes(output.Rejected[rejectedStart:]), "warnings": debugWarningEnvelopes(extractWarnings)}}); err != nil { return err } if len(values) == 0 { @@ -254,18 +230,18 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C mergeInputs[i] = contracts.ExtractArtifact[any]{LaneID: value.LaneID, ExtractorKey: value.ExtractorKey, SourceID: value.SourceID, ChunkID: value.ChunkID, ChunkIndex: value.ChunkIndex, ChunkRef: value.ChunkRef, Value: value.Value} } mergeDeps := artifactCheckpointDigests(serializedExtracts) - mergeCP, mergeDecision := loadArtifactMerge(loader, lane.ID, lane.Merge.Module, mergeDeps) + mergeCP, mergeDecision := loadMerge(loader, lane.ID, lane.Merge.Module, mergeDeps) if mergeDecision.Reused { if _, decodeErr := decodeCheckpointArtifact(typed.codec, mergeCP.Output); decodeErr != nil { mergeDecision = CheckpointDecision{Reason: "merge artifact checkpoint codec is incompatible: " + decodeErr.Error()} } } recordCheckpointEvent(output, loader, string(StageMerge), lane.ID, lane.Merge.Module, mergeDecision) - if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugArtifactCheckpointOutputs(serializedExtracts), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageMerge), LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "decision": mergeDecision, "source": debugSourceDocumentEnvelope(doc), "extract_outputs": debugCheckpointArtifacts(serializedExtracts), "options": redactSensitiveMap(lane.Merge.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil { return err } var merged erasedMergeArtifact - var serializedMerge ArtifactCheckpointOutput + var serializedMerge CheckpointArtifact var mergeWarnings []contracts.Warning if mergeDecision.Reused { value, decodeErr := decodeCheckpointArtifact(typed.codec, mergeCP.Output) @@ -273,7 +249,7 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C return fmt.Errorf("decode merge checkpoint for lane %q: %w", lane.ID, decodeErr) } merged = erasedMergeArtifact{LaneID: lane.ID, MergerKey: lane.Merge.Module, SourceID: doc.ID, Value: value} - serializedMerge = hydrateCheckpointArtifact(typed.codec, cloneArtifactCheckpointOutput(mergeCP.Output), value) + serializedMerge = hydrateCheckpointArtifact(typed.codec, cloneCheckpointArtifact(mergeCP.Output), value) mergeWarnings = cloneWarnings(mergeCP.Warnings) output.Warnings = append(output.Warnings, mergeWarnings...) } else { @@ -310,33 +286,33 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C return nil } output.Warnings = append(output.Warnings, mergeWarnings...) - if err := recordArtifactMerge(checkpoints, lane.ID, lane.Merge.Module, mergeDeps, serializedMerge, mergeWarnings); err != nil { + if err := recordMerge(checkpoints, lane.ID, lane.Merge.Module, mergeDeps, serializedMerge, mergeWarnings); err != nil { return err } } - if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugArtifactCheckpointOutput(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("merge", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageMerge), LaneID: lane.ID, ModuleKey: lane.Merge.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": mergeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedMerge), "warnings": debugWarningEnvelopes(mergeWarnings)}}); err != nil { return err } - normalizeDeps := artifactCheckpointDigests([]ArtifactCheckpointOutput{serializedMerge}) - normalizeCP, normalizeDecision := loadArtifactNormalize(loader, lane.ID, lane.Normalize.Module, normalizeDeps) + normalizeDeps := artifactCheckpointDigests([]CheckpointArtifact{serializedMerge}) + normalizeCP, normalizeDecision := loadNormalize(loader, lane.ID, lane.Normalize.Module, normalizeDeps) if normalizeDecision.Reused { if _, decodeErr := decodeCheckpointArtifact(typed.codec, normalizeCP.Output); decodeErr != nil { normalizeDecision = CheckpointDecision{Reason: "normalize artifact checkpoint codec is incompatible: " + decodeErr.Error()} } } recordCheckpointEvent(output, loader, string(StageNormalize), lane.ID, lane.Normalize.Module, normalizeDecision) - if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugArtifactCheckpointOutput(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "input.json"), debugTimedEnvelope{Stage: string(StageNormalize), LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "decision": normalizeDecision, "source": debugSourceDocumentEnvelope(doc), "merge_output": debugCheckpointArtifact(serializedMerge), "options": redactSensitiveMap(lane.Normalize.Options), "metadata": redactSensitiveMap(input.Metadata)}}); err != nil { return err } - var serializedNormalize ArtifactCheckpointOutput + var serializedNormalize CheckpointArtifact var normalizeWarnings []contracts.Warning if normalizeDecision.Reused { value, decodeErr := decodeCheckpointArtifact(typed.codec, normalizeCP.Output) if decodeErr != nil { return fmt.Errorf("decode normalize checkpoint for lane %q: %w", lane.ID, decodeErr) } - serializedNormalize, normalizeWarnings = hydrateCheckpointArtifact(typed.codec, cloneArtifactCheckpointOutput(normalizeCP.Output), value), cloneWarnings(normalizeCP.Warnings) + serializedNormalize, normalizeWarnings = hydrateCheckpointArtifact(typed.codec, cloneCheckpointArtifact(normalizeCP.Output), value), cloneWarnings(normalizeCP.Warnings) output.Warnings = append(output.Warnings, normalizeWarnings...) } else { if err := checkpoints.NormalizeRunning(lane.ID, lane.Normalize.Module, normalizeDeps); err != nil { @@ -371,11 +347,11 @@ func (r *Runner) runTypedLane(ctx context.Context, input RunInput, checkpoints C return nil } output.Warnings = append(output.Warnings, normalizeWarnings...) - if err := recordArtifactNormalize(checkpoints, lane.ID, lane.Normalize.Module, normalizeDeps, serializedNormalize, normalizeWarnings); err != nil { + if err := recordNormalize(checkpoints, lane.ID, lane.Normalize.Module, normalizeDeps, serializedNormalize, normalizeWarnings); err != nil { return err } } - if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugArtifactCheckpointOutput(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil { + if err := writeDebugTimed(input.Debug, path.Join("normalize", debugPathComponent(lane.ID), "output.json"), debugTimedEnvelope{Stage: string(StageNormalize), LaneID: lane.ID, ModuleKey: lane.Normalize.Module, StartedAt: time.Now().UTC(), Payload: map[string]any{"reused": normalizeDecision.Reused, "accepted": true, "output": debugCheckpointArtifact(serializedNormalize), "warnings": debugWarningEnvelopes(normalizeWarnings)}}); err != nil { return err } output.NormalizeOutputs = append(output.NormalizeOutputs, contracts.SerializedOutput{LaneID: lane.ID, NormalizerKey: lane.Normalize.Module, SourceID: doc.ID, Artifact: contracts.CloneSerializedArtifact(serializedNormalize.Artifact)}) @@ -430,7 +406,7 @@ func (r *Runner) validateTypedArtifact(ctx context.Context, codec artifactCodecE return nil, nil, fmt.Errorf("validator %q is incompatible with typed artifact validation", binding.Module) } artifact, _ := serializeArtifact(codec, target.value, true) - debugCall := debugValidationCall{ValidatorName: binding.Module, Request: map[string]any{"stage": string(target.stage), "lane_id": target.laneID, "module_key": target.moduleKey, "source_id": target.sourceID, "artifact": debugArtifactCheckpointOutput(ArtifactCheckpointOutput{Artifact: artifact, SchemaDigest: contracts.DigestArtifactSchema(artifact.Schema)}), "metadata": redactSensitiveMap(target.metadata)}, Result: debugValidationResultEnvelope(result)} + debugCall := debugValidationCall{ValidatorName: binding.Module, Request: map[string]any{"stage": string(target.stage), "lane_id": target.laneID, "module_key": target.moduleKey, "source_id": target.sourceID, "artifact": debugCheckpointArtifact(CheckpointArtifact{Artifact: artifact, SchemaDigest: contracts.DigestArtifactSchema(artifact.Schema)}), "metadata": redactSensitiveMap(target.metadata)}, Result: debugValidationResultEnvelope(result)} if err != nil { debugCall.Error = err.Error() } diff --git a/internal/framework/pipeline/runner_typed_checkpoint_test.go b/internal/framework/pipeline/runner_typed_checkpoint_test.go index b668831..70396f3 100644 --- a/internal/framework/pipeline/runner_typed_checkpoint_test.go +++ b/internal/framework/pipeline/runner_typed_checkpoint_test.go @@ -20,22 +20,22 @@ func TestDecodeCheckpointArtifactRejectsIncompatibleCodecIdentityAndBytes(t *tes if err != nil { t.Fatalf("serializeArtifact: %v", err) } - base := ArtifactCheckpointOutput{Artifact: artifact, SchemaDigest: contracts.DigestArtifactSchema(artifact.Schema)} + base := CheckpointArtifact{Artifact: artifact, SchemaDigest: contracts.DigestArtifactSchema(artifact.Schema)} tests := []struct { name string - mutate func(*ArtifactCheckpointOutput) + mutate func(*CheckpointArtifact) want string }{ - {name: "missing kind", mutate: func(v *ArtifactCheckpointOutput) { v.Artifact.Kind = "" }, want: "artifact kind"}, - {name: "schema version", mutate: func(v *ArtifactCheckpointOutput) { v.Artifact.Schema.Version = "v999" }, want: "does not match codec schema"}, - {name: "schema digest", mutate: func(v *ArtifactCheckpointOutput) { v.SchemaDigest = "sha256:different" }, want: "schema digest"}, - {name: "media type", mutate: func(v *ArtifactCheckpointOutput) { v.Artifact.MediaType = "text/plain" }, want: "media type"}, - {name: "decode failure", mutate: func(v *ArtifactCheckpointOutput) { v.Artifact.Content = []byte(`{"items":[`) }, want: "unexpected EOF"}, + {name: "missing kind", mutate: func(v *CheckpointArtifact) { v.Artifact.Kind = "" }, want: "artifact kind"}, + {name: "schema version", mutate: func(v *CheckpointArtifact) { v.Artifact.Schema.Version = "v999" }, want: "does not match codec schema"}, + {name: "schema digest", mutate: func(v *CheckpointArtifact) { v.SchemaDigest = "sha256:different" }, want: "schema digest"}, + {name: "media type", mutate: func(v *CheckpointArtifact) { v.Artifact.MediaType = "text/plain" }, want: "media type"}, + {name: "decode failure", mutate: func(v *CheckpointArtifact) { v.Artifact.Content = []byte(`{"items":[`) }, want: "unexpected EOF"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { - stored := cloneArtifactCheckpointOutput(base) + stored := cloneCheckpointArtifact(base) test.mutate(&stored) if _, err := decodeCheckpointArtifact(codec, stored); err == nil || !strings.Contains(err.Error(), test.want) { t.Fatalf("decode error = %v, want %q", err, test.want) diff --git a/internal/framework/pipeline/typed_resolution_test.go b/internal/framework/pipeline/typed_resolution_test.go index 332a128..2b743ae 100644 --- a/internal/framework/pipeline/typed_resolution_test.go +++ b/internal/framework/pipeline/typed_resolution_test.go @@ -55,6 +55,40 @@ func (typedTestChunkValidator) Validate(context.Context, contracts.ChunkValidati type typedTestSerializedValidator struct{ key string } +type typedTestInput struct { + key string + doc *source.SourceDocument +} + +func (v *typedTestInput) Key() string { return v.key } +func (v *typedTestInput) Parse(context.Context, contracts.ParseRequest) (*source.SourceDocument, error) { + return v.doc, nil +} + +type typedTestChunker struct { + key string + chunks []source.Chunk +} + +func (v *typedTestChunker) Key() string { return v.key } +func (v *typedTestChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil } +func (v *typedTestChunker) Chunk(context.Context, contracts.ChunkRequest) (contracts.ChunkResult, error) { + return contracts.ChunkResult{Chunks: v.chunks}, nil +} + +type typedTestOutput struct{ key string } + +func (v *typedTestOutput) Key() string { return v.key } +func (v *typedTestOutput) Encode(context.Context, contracts.OutputRequest) (contracts.OutputResult, error) { + return contracts.OutputResult{}, nil +} +func typedTestDocument() *source.SourceDocument { + ref := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1} + doc := &source.SourceDocument{ID: "source", Kind: "document", Format: "text/plain", Units: []source.SourceUnit{{ID: 1, Kind: "line", Text: "text", Ref: ref}}} + doc.Digest, _ = source.DigestDocument(doc) + return doc +} + func (v typedTestSerializedValidator) Name() string { return v.key } func (typedTestSerializedValidator) ExecutionClass() contracts.ExecutionClass { return contracts.ExecutionClassDeterministic @@ -177,20 +211,6 @@ func TestResolveTypedLaneRejectsIncompatibleComposition(t *testing.T) { } } -func TestLegacyRawRegistrationCannotSatisfyTypedLane(t *testing.T) { - options := completeTypedCatalogOptions() - options.registerScoreMerger = false - catalog := typedResolutionCatalog(t, options) - if err := catalog.Mergers.RegisterLegacyRaw("typed/merge", func() (contracts.LegacyRawMerger, error) { return nil, nil }); err != nil { - t.Fatalf("RegisterLegacyRaw() error = %v, want nil", err) - } - - _, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, catalog) - if err == nil || !strings.Contains(err.Error(), `no typed variant for artifact kind "test/score"`) { - t.Fatalf("ResolvePipeline() error = %v, want typed variant error", err) - } -} - func TestTypedVariantRegistrationRejectsDuplicates(t *testing.T) { registry := NewMergerRegistry() spec := ModuleSpec{Key: "typed/merge", Stage: StageMerge, ArtifactKind: "test/notes"} @@ -205,6 +225,48 @@ func TestTypedVariantRegistrationRejectsDuplicates(t *testing.T) { } } +func TestConstructorRegistrationsRejectUnconfiguredOptions(t *testing.T) { + extractors := NewExtractorRegistry() + if err := RegisterExtractor(extractors, ModuleSpec{Key: "typed/extract", Stage: StageExtract, ArtifactKind: "test/notes"}, func() (contracts.Extractor[codecNotes], error) { + return typedTestExtractor[codecNotes]{key: "typed/extract"}, nil + }); err != nil { + t.Fatalf("RegisterExtractor() error = %v", err) + } + if err := extractors.validateOptions("typed/extract", map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("extractor option validation error = %v, want unknown option", err) + } + + mergers := NewMergerRegistry() + if err := RegisterMerger(mergers, ModuleSpec{Key: "typed/merge", Stage: StageMerge, ArtifactKind: "test/notes"}, func() (contracts.Merger[codecNotes], error) { + return typedTestMerger[codecNotes]{key: "typed/merge"}, nil + }); err != nil { + t.Fatalf("RegisterMerger() error = %v", err) + } + if err := mergers.validateOptions("typed/merge", "test/notes", map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("merger option validation error = %v, want unknown option", err) + } + + normalizers := NewNormalizerRegistry() + if err := RegisterNormalizer(normalizers, ModuleSpec{Key: "typed/normalize", Stage: StageNormalize, ArtifactKind: "test/notes"}, func() (contracts.Normalizer[codecNotes], error) { + return typedTestNormalizer[codecNotes]{key: "typed/normalize"}, nil + }); err != nil { + t.Fatalf("RegisterNormalizer() error = %v", err) + } + if err := normalizers.validateOptions("typed/normalize", "test/notes", map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("normalizer option validation error = %v, want unknown option", err) + } + + validators := NewValidatorRegistry() + if err := RegisterTypedValidator(validators, "test/notes", ValidatorSpec{Key: "typed/check", ExecutionClass: contracts.ExecutionClassDeterministic}, func() (contracts.TypedValidator[codecNotes], error) { + return typedTestValidator[codecNotes]{key: "typed/check"}, nil + }); err != nil { + t.Fatalf("RegisterTypedValidator() error = %v", err) + } + if err := validators.validateOptions(ResolvedValidator{Binding: ModuleBinding{Module: "typed/check", Options: map[string]any{"unexpected": true}}, Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}); err == nil || !strings.Contains(err.Error(), "unknown option") { + t.Fatalf("validator option validation error = %v, want unknown option", err) + } +} + func TestResolvedPipelineDigestIncludesArtifactSchemaIdentity(t *testing.T) { baseOptions := completeTypedCatalogOptions() base, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, typedResolutionCatalog(t, baseOptions)) @@ -302,18 +364,18 @@ func typedResolutionCatalog(t *testing.T, options typedCatalogOptions) ModuleCat func mustRegisterTypedTestBase(t *testing.T, catalog ModuleCatalog) { t.Helper() if err := catalog.Inputs.RegisterWithSpec(ModuleSpec{Key: "typed/input", Stage: StageInput}, func() (contracts.InputAdapter, error) { - return &runnerInputAdapter{key: "typed/input", doc: validSourceDocument()}, nil + return &typedTestInput{key: "typed/input", doc: typedTestDocument()}, nil }); err != nil { t.Fatalf("register input: %v", err) } if err := catalog.Chunkers.RegisterWithSpec(ModuleSpec{Key: "typed/chunk", Stage: StageChunk}, func() (contracts.Chunker, error) { - doc := validSourceDocument() - return &runnerChunker{key: "typed/chunk", chunks: []source.Chunk{{ID: "chunk-1", SourceID: doc.ID, Index: 0, Ref: doc.Units[0].Ref, Content: []byte(`{"chunk":1}`), MediaType: "application/json", Units: []source.SourceUnit{doc.Units[0]}}}}, nil + doc := typedTestDocument() + return &typedTestChunker{key: "typed/chunk", chunks: []source.Chunk{{ID: "chunk-1", SourceID: doc.ID, Index: 0, Ref: doc.Units[0].Ref, Content: []byte(`{"chunk":1}`), MediaType: "application/json", Units: []source.SourceUnit{doc.Units[0]}}}}, nil }); err != nil { t.Fatalf("register chunker: %v", err) } if err := catalog.Outputs.RegisterWithSpec(ModuleSpec{Key: "typed/output", Stage: StageOutput}, func() (contracts.OutputEncoder, error) { - return &runnerOutputEncoder{key: "typed/output"}, nil + return &typedTestOutput{key: "typed/output"}, nil }); err != nil { t.Fatalf("register output: %v", err) } diff --git a/internal/framework/pipeline/validator_registry.go b/internal/framework/pipeline/validator_registry.go index 8f067dd..52d1b0e 100644 --- a/internal/framework/pipeline/validator_registry.go +++ b/internal/framework/pipeline/validator_registry.go @@ -10,9 +10,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) -type LegacyRawValidatorConstructor func() (contracts.LegacyRawValidator, error) -type LegacyRawValidatorBuilder func(BuildRequest) (contracts.LegacyRawValidator, error) - type ValidatorSpec struct { Key string `json:"key"` ExecutionClass contracts.ExecutionClass `json:"execution_class"` @@ -27,16 +24,12 @@ type SerializedValidatorSpec struct { type ValidatorTarget string const ( - ValidatorTargetLegacyRaw ValidatorTarget = "legacy_raw" ValidatorTargetChunk ValidatorTarget = "chunk" ValidatorTargetSerialized ValidatorTarget = "serialized" ValidatorTargetTyped ValidatorTarget = "typed" ) type ValidatorRegistry struct { - legacyBuilders map[string]LegacyRawValidatorBuilder - legacyValidators map[string]OptionValidator - legacySpecs map[string]ValidatorSpec typedEntries map[artifactVariantKey]typedValidatorEntry chunkEntries map[string]chunkValidatorEntry serializedEntries map[string]serializedValidatorEntry @@ -65,65 +58,17 @@ type serializedValidatorEntry struct { func NewValidatorRegistry() *ValidatorRegistry { return &ValidatorRegistry{ - legacyBuilders: make(map[string]LegacyRawValidatorBuilder), - legacyValidators: make(map[string]OptionValidator), - legacySpecs: make(map[string]ValidatorSpec), typedEntries: make(map[artifactVariantKey]typedValidatorEntry), chunkEntries: make(map[string]chunkValidatorEntry), serializedEntries: make(map[string]serializedValidatorEntry), } } -func (r *ValidatorRegistry) RegisterLegacyRaw(key string, constructor LegacyRawValidatorConstructor) error { - return r.RegisterLegacyRawWithSpec(ValidatorSpec{Key: key, ExecutionClass: contracts.ExecutionClassDeterministic}, constructor) -} - -func (r *ValidatorRegistry) RegisterLegacyRawWithSpec(spec ValidatorSpec, constructor LegacyRawValidatorConstructor) error { - if constructor == nil { - return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key)) - } - return r.RegisterLegacyRawBuilderWithSpec(spec, allowLegacyOptions, func(BuildRequest) (contracts.LegacyRawValidator, error) { - return constructor() - }) -} - -func (r *ValidatorRegistry) RegisterLegacyRawBuilderWithSpec(spec ValidatorSpec, validateOptions OptionValidator, builder LegacyRawValidatorBuilder) error { - if r == nil { - return fmt.Errorf("validator registry must not be nil") - } - normalizedSpec, err := normalizeValidatorSpec(spec) - if err != nil { - return err - } - if validateOptions == nil { - return fmt.Errorf("validator option validator for %q must not be nil", normalizedSpec.Key) - } - if builder == nil { - return fmt.Errorf("validator builder for %q must not be nil", normalizedSpec.Key) - } - if _, ok := r.legacyBuilders[normalizedSpec.Key]; ok { - return fmt.Errorf("legacy raw validator %q is already registered", normalizedSpec.Key) - } - if r.legacyBuilders == nil { - r.legacyBuilders = make(map[string]LegacyRawValidatorBuilder) - } - if r.legacyValidators == nil { - r.legacyValidators = make(map[string]OptionValidator) - } - if r.legacySpecs == nil { - r.legacySpecs = make(map[string]ValidatorSpec) - } - r.legacyBuilders[normalizedSpec.Key] = builder - r.legacyValidators[normalizedSpec.Key] = validateOptions - r.legacySpecs[normalizedSpec.Key] = normalizedSpec - return nil -} - func RegisterTypedValidator[T any](registry *ValidatorRegistry, kind contracts.ArtifactKind, spec ValidatorSpec, constructor func() (contracts.TypedValidator[T], error)) error { if constructor == nil { return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return RegisterTypedValidatorBuilder(registry, kind, spec, allowLegacyOptions, func(BuildRequest) (contracts.TypedValidator[T], error) { + return RegisterTypedValidatorBuilder(registry, kind, spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.TypedValidator[T], error) { return constructor() }) } @@ -180,7 +125,7 @@ func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, con if constructor == nil { return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return RegisterChunkValidatorBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.ChunkValidator, error) { + return RegisterChunkValidatorBuilder(registry, spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.ChunkValidator, error) { return constructor() }) } @@ -213,7 +158,7 @@ func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedVal if constructor == nil { return fmt.Errorf("validator constructor for %q must not be nil", strings.TrimSpace(spec.Key)) } - return RegisterSerializedValidatorBuilder(registry, spec, allowLegacyOptions, func(BuildRequest) (contracts.SerializedValidator, error) { + return RegisterSerializedValidatorBuilder(registry, spec, rejectUnconfiguredOptions, func(BuildRequest) (contracts.SerializedValidator, error) { return constructor() }) } @@ -246,39 +191,6 @@ func RegisterSerializedValidatorBuilder(registry *ValidatorRegistry, spec Serial return nil } -func (r *ValidatorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawValidator, error) { - return r.BuildLegacyRawWithRequest(key, BuildRequest{}) -} - -func (r *ValidatorRegistry) BuildLegacyRawWithRequest(key string, request BuildRequest) (contracts.LegacyRawValidator, error) { - if r == nil { - return nil, fmt.Errorf("validator registry must not be nil") - } - normalizedKey := strings.TrimSpace(key) - if normalizedKey == "" { - return nil, fmt.Errorf("validator key must not be empty") - } - builder, ok := r.legacyBuilders[normalizedKey] - if !ok { - return nil, fmt.Errorf("legacy raw validator %q is not registered", normalizedKey) - } - validator, err := builder(cloneBuildRequest(request)) - if err != nil { - return nil, fmt.Errorf("build validator %q: %w", normalizedKey, err) - } - if validator == nil { - return nil, fmt.Errorf("validator %q constructor returned nil", normalizedKey) - } - if validator.Name() != normalizedKey { - return nil, fmt.Errorf("validator %q returned name %q", normalizedKey, validator.Name()) - } - spec := r.legacySpecs[normalizedKey] - if validator.ExecutionClass() != spec.ExecutionClass { - return nil, fmt.Errorf("validator %q returned execution class %q, want %q", normalizedKey, validator.ExecutionClass(), spec.ExecutionClass) - } - return validator, nil -} - func (r *ValidatorRegistry) validateOptions(resolved ResolvedValidator) error { if r == nil { return fmt.Errorf("validator registry must not be nil") @@ -301,8 +213,6 @@ func (r *ValidatorRegistry) validateOptions(resolved ResolvedValidator) error { if ok { validator = entry.validateOptions } - default: - validator = r.legacyValidators[key] } if validator == nil { return fmt.Errorf("validator %q construction entry is not registered", key) @@ -314,10 +224,6 @@ func (r *ValidatorRegistry) Spec(key string) (ValidatorSpec, bool) { if r == nil { return ValidatorSpec{}, false } - spec, ok := r.legacySpecs[strings.TrimSpace(key)] - if ok { - return spec, true - } normalized := strings.TrimSpace(key) if entry, found := r.chunkEntries[normalized]; found { return entry.spec, true @@ -329,7 +235,7 @@ func (r *ValidatorRegistry) Spec(key string) (ValidatorSpec, bool) { entry, found := r.typedEntry(normalized, kinds[0]) return entry.spec, found } - return spec, ok + return ValidatorSpec{}, false } func (r *ValidatorRegistry) typedEntry(key string, kind contracts.ArtifactKind) (typedValidatorEntry, bool) { @@ -372,13 +278,15 @@ func (r *ValidatorRegistry) registeredTypedKinds(key string) []contracts.Artifac } func (r *ValidatorRegistry) RegisteredSpecs() []ValidatorSpec { - if r == nil || len(r.legacySpecs) == 0 { + if r == nil { return nil } - keys := sortedRegistryKeys(r.legacySpecs) + keys := r.RegisteredKeys() specs := make([]ValidatorSpec, 0, len(keys)) for _, key := range keys { - specs = append(specs, r.legacySpecs[key]) + if spec, ok := r.Spec(key); ok { + specs = append(specs, spec) + } } return specs } @@ -388,9 +296,6 @@ func (r *ValidatorRegistry) RegisteredKeys() []string { return nil } keys := make(map[string]struct{}) - for key := range r.legacySpecs { - keys[key] = struct{}{} - } for key := range r.typedEntries { keys[key.module] = struct{}{} } diff --git a/internal/framework/pipeline/validator_registry_test.go b/internal/framework/pipeline/validator_registry_test.go deleted file mode 100644 index ecbcc81..0000000 --- a/internal/framework/pipeline/validator_registry_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package pipeline - -import ( - "context" - "reflect" - "strings" - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -func TestValidatorRegistryBehavior(t *testing.T) { - registry := NewValidatorRegistry() - if err := registry.RegisterLegacyRaw(" generic-validator ", validatorConstructor("generic-validator", contracts.ExecutionClassDeterministic)); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - - validator, err := registry.BuildLegacyRaw("generic-validator") - if err != nil { - t.Fatalf("Build() error = %v, want nil", err) - } - if validator.Name() != "generic-validator" { - t.Fatalf("validator name = %q, want generic-validator", validator.Name()) - } - - spec, ok := registry.Spec(" generic-validator ") - if !ok { - t.Fatal("Spec() ok = false, want true") - } - want := ValidatorSpec{Key: "generic-validator", ExecutionClass: contracts.ExecutionClassDeterministic} - if !reflect.DeepEqual(spec, want) { - t.Fatalf("Spec() = %#v, want %#v", spec, want) - } -} - -func TestValidatorRegistryRegistersSpecs(t *testing.T) { - registry := NewValidatorRegistry() - spec := ValidatorSpec{Key: " llm-validator ", ExecutionClass: contracts.ExecutionClassLLMBacked} - if err := registry.RegisterLegacyRawWithSpec(spec, validatorConstructor("llm-validator", contracts.ExecutionClassLLMBacked)); err != nil { - t.Fatalf("RegisterWithSpec() error = %v, want nil", err) - } - - got, ok := registry.Spec("llm-validator") - if !ok { - t.Fatal("Spec() ok = false, want true") - } - want := ValidatorSpec{Key: "llm-validator", ExecutionClass: contracts.ExecutionClassLLMBacked} - if !reflect.DeepEqual(got, want) { - t.Fatalf("Spec() = %#v, want %#v", got, want) - } -} - -func TestValidatorRegistryRegisteredSpecsAreSorted(t *testing.T) { - registry := NewValidatorRegistry() - for _, key := range []string{"zeta", "alpha"} { - if err := registry.RegisterLegacyRaw(key, validatorConstructor(key, contracts.ExecutionClassDeterministic)); err != nil { - t.Fatalf("Register(%q) error = %v", key, err) - } - } - - specs := registry.RegisteredSpecs() - if len(specs) != 2 || specs[0].Key != "alpha" || specs[1].Key != "zeta" { - t.Fatalf("RegisteredSpecs() = %#v, want sorted specs", specs) - } -} - -func TestValidatorRegistryRejectsUnsupportedExecutionClass(t *testing.T) { - registry := NewValidatorRegistry() - err := registry.RegisterLegacyRawWithSpec( - ValidatorSpec{Key: "invalid-validator", ExecutionClass: contracts.ExecutionClass("unsupported")}, - validatorConstructor("invalid-validator", contracts.ExecutionClass("unsupported")), - ) - if err == nil { - t.Fatal("RegisterWithSpec() error = nil, want unsupported execution class error") - } -} - -func TestValidatorRegistryRejectsConstructorExecutionClassMismatch(t *testing.T) { - registry := NewValidatorRegistry() - if err := registry.RegisterLegacyRawWithSpec( - ValidatorSpec{Key: "validator", ExecutionClass: contracts.ExecutionClassDeterministic}, - validatorConstructor("validator", contracts.ExecutionClassLLMBacked), - ); err != nil { - t.Fatalf("RegisterWithSpec() error = %v, want nil", err) - } - - _, err := registry.BuildLegacyRaw("validator") - if err == nil { - t.Fatal("Build() error = nil, want execution class mismatch") - } - if !strings.Contains(err.Error(), "execution class") { - t.Fatalf("Build() error = %q, want execution class context", err.Error()) - } -} - -type testValidator struct { - name string - executionClass contracts.ExecutionClass -} - -func validatorConstructor(name string, executionClass contracts.ExecutionClass) LegacyRawValidatorConstructor { - return func() (contracts.LegacyRawValidator, error) { - return testValidator{name: name, executionClass: executionClass}, nil - } -} - -func (validator testValidator) Name() string { - return validator.name -} - -func (validator testValidator) ExecutionClass() contracts.ExecutionClass { - return validator.executionClass -} - -func (validator testValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - return contracts.ValidationResult{Approved: true}, nil -} diff --git a/internal/framework/pipeline/walking_skeleton_test.go b/internal/framework/pipeline/walking_skeleton_test.go deleted file mode 100644 index 359a7fe..0000000 --- a/internal/framework/pipeline/walking_skeleton_test.go +++ /dev/null @@ -1,467 +0,0 @@ -package pipeline - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "os" - "reflect" - "strings" - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" - "gitea.maximumdirect.net/eric/notarius/internal/core/source" - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" -) - -func TestWalkingSkeletonFixture(t *testing.T) { - inputBytes := readTestFixture(t, "testdata/walking_skeleton_input.json") - expectedBytes := readTestFixture(t, "testdata/walking_skeleton_output.json") - llmClient := &walkingSkeletonLLMClient{} - - resolved, err := ResolvePipeline(walkingSkeletonProfile(), ResolveOptions{}, walkingSkeletonCatalog(t)) - if err != nil { - t.Fatalf("ResolvePipeline() error = %v, want nil", err) - } - - output, err := newPreparedRunner(t, walkingSkeletonRegistries(t)).Run(context.Background(), RunInput{ - pipeline: resolved, - SourceID: "fixture-source", - Path: "walking_skeleton_input.json", - RawInput: inputBytes, - llmClient: llmClient, - }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) - } - if len(output.OutputFiles) != 1 { - t.Fatalf("len(OutputFiles) = %d, want 1", len(output.OutputFiles)) - } - if output.OutputFiles[0].ContentType != "application/json" { - t.Fatalf("ContentType = %q, want application/json", output.OutputFiles[0].ContentType) - } - assertStructuralJSONEqual(t, output.OutputFiles[0].Bytes, expectedBytes) - if llmClient.calls != 3 { - t.Fatalf("LLM calls = %d, want extractor calls plus normalizer call", llmClient.calls) - } -} - -func TestWalkingSkeletonResolutionRejectsMissingCapability(t *testing.T) { - catalog := walkingSkeletonCatalog(t) - catalog.Extractors = NewExtractorRegistry() - if err := catalog.Extractors.RegisterLegacyRawWithSpec(ModuleSpec{ - Key: "fake/extract", - Stage: StageExtract, - Requires: []string{"missing"}, - Provides: []string{"fake_artifacts"}, - }, func() (contracts.LegacyRawExtractor, error) { - return walkingSkeletonExtractor{}, nil - }); err != nil { - t.Fatalf("RegisterWithSpec() error = %v, want nil", err) - } - - _, err := ResolvePipeline(walkingSkeletonProfile(), ResolveOptions{}, catalog) - if err == nil { - t.Fatal("ResolvePipeline() error = nil, want error") - } - if !strings.Contains(err.Error(), "missing") { - t.Fatalf("ResolvePipeline() error = %q, want missing capability", err.Error()) - } -} - -func TestWalkingSkeletonResolutionRejectsUnknownOnlyLane(t *testing.T) { - _, err := ResolvePipeline(walkingSkeletonProfile(), ResolveOptions{Only: []string{"missing"}}, walkingSkeletonCatalog(t)) - if err == nil { - t.Fatal("ResolvePipeline() error = nil, want error") - } - if !strings.Contains(err.Error(), "missing") || !strings.Contains(err.Error(), "not declared") { - t.Fatalf("ResolvePipeline() error = %q, want unknown lane error", err.Error()) - } -} - -func walkingSkeletonProfile() PipelineProfile { - return PipelineProfile{ - ID: "walking-skeleton", - Input: Binding("fake/input"), - Chunk: Binding("fake/chunk"), - Output: Binding("json"), - Artifacts: map[string]ArtifactLaneProfile{ - "events": {Extract: Binding("fake/extract")}, - }, - } -} - -func walkingSkeletonCatalog(t *testing.T) ModuleCatalog { - t.Helper() - - catalog := ModuleCatalog{ - Inputs: NewInputAdapterRegistry(), - Chunkers: NewChunkerRegistry(), - ArtifactCodecs: NewArtifactCodecRegistry(), - Extractors: NewExtractorRegistry(), - Mergers: NewMergerRegistry(), - Normalizers: NewNormalizerRegistry(), - ValidatorChains: NewValidatorChainRegistry(), - Outputs: NewOutputEncoderRegistry(), - } - if err := catalog.Inputs.RegisterWithSpec(ModuleSpec{ - Key: "fake/input", - Stage: StageInput, - Provides: []string{"plain_text"}, - }, func() (contracts.InputAdapter, error) { - return walkingSkeletonInput{}, nil - }); err != nil { - t.Fatalf("register fake input: %v", err) - } - if err := catalog.Chunkers.RegisterWithSpec(ModuleSpec{ - Key: "fake/chunk", - Stage: StageChunk, - Requires: []string{"plain_text"}, - Provides: []string{"chunks"}, - }, func() (contracts.Chunker, error) { - return walkingSkeletonChunker{}, nil - }); err != nil { - t.Fatalf("register fake chunker: %v", err) - } - if err := catalog.Extractors.RegisterLegacyRawWithSpec(ModuleSpec{ - Key: "fake/extract", - Stage: StageExtract, - Requires: []string{"chunks"}, - Provides: []string{"fake_artifacts"}, - }, func() (contracts.LegacyRawExtractor, error) { - return walkingSkeletonExtractor{}, nil - }); err != nil { - t.Fatalf("register fake extractor: %v", err) - } - if err := catalog.Mergers.RegisterLegacyRawWithSpec(ModuleSpec{ - Key: DefaultMergeModule, - Stage: StageMerge, - Requires: []string{"fake_artifacts"}, - }, func() (contracts.LegacyRawMerger, error) { - return walkingSkeletonMerger{}, nil - }); err != nil { - t.Fatalf("register append-order merger: %v", err) - } - if err := catalog.Normalizers.RegisterLegacyRawWithSpec(ModuleSpec{ - Key: DefaultNormalizeModule, - Stage: StageNormalize, - }, func() (contracts.LegacyRawNormalizer, error) { - return walkingSkeletonNormalizer{}, nil - }); err != nil { - t.Fatalf("register no-op normalizer: %v", err) - } - if err := catalog.Outputs.RegisterWithSpec(ModuleSpec{ - Key: "json", - Stage: StageOutput, - }, func() (contracts.OutputEncoder, error) { - return walkingSkeletonOutput{}, nil - }); err != nil { - t.Fatalf("register fake output: %v", err) - } - return catalog -} - -func walkingSkeletonRegistries(t *testing.T) Registries { - t.Helper() - - catalog := walkingSkeletonCatalog(t) - return Registries{ - Inputs: catalog.Inputs, - Chunkers: catalog.Chunkers, - ArtifactCodecs: catalog.ArtifactCodecs, - Extractors: catalog.Extractors, - Mergers: catalog.Mergers, - Normalizers: catalog.Normalizers, - Outputs: catalog.Outputs, - } -} - -type walkingSkeletonInput struct{} - -func (input walkingSkeletonInput) Key() string { - return "fake/input" -} - -func (input walkingSkeletonInput) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) { - var fixture struct { - ID string `json:"id"` - Units []struct { - ID int `json:"id"` - Text string `json:"text"` - } `json:"units"` - } - if err := json.Unmarshal(req.Raw, &fixture); err != nil { - return nil, err - } - - units := make([]source.SourceUnit, 0, len(fixture.Units)) - for _, unit := range fixture.Units { - units = append(units, source.SourceUnit{ - ID: unit.ID, - Kind: "unit", - Text: unit.Text, - Ref: source.SourceRef{ - SourceID: fixture.ID, - StartUnitID: unit.ID, - EndUnitID: unit.ID, - }, - }) - } - return &source.SourceDocument{ - ID: fixture.ID, - Kind: "fixture", - Format: "application/json", - Digest: rawDigest(req.Raw), - Units: units, - }, nil -} - -type walkingSkeletonChunker struct{} - -func (chunker walkingSkeletonChunker) Key() string { - return "fake/chunk" -} - -func (chunker walkingSkeletonChunker) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (chunker walkingSkeletonChunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkResult, error) { - if len(req.Source.Units) < 3 { - return contracts.ChunkResult{}, fmt.Errorf("fixture source must contain at least three units") - } - return contracts.ChunkResult{ - Chunks: []source.Chunk{ - { - ID: req.Source.ID + ":chunk:0", - SourceID: req.Source.ID, - Index: 0, - Ref: source.SourceRef{SourceID: req.Source.ID, StartUnitID: req.Source.Units[0].ID, EndUnitID: req.Source.Units[1].ID}, - Content: []byte(`{"units":[1,2]}`), - MediaType: "application/json", - Units: append([]source.SourceUnit(nil), req.Source.Units[:2]...), - }, - { - ID: req.Source.ID + ":chunk:1", - SourceID: req.Source.ID, - Index: 1, - Ref: source.SourceRef{SourceID: req.Source.ID, StartUnitID: req.Source.Units[2].ID, EndUnitID: req.Source.Units[len(req.Source.Units)-1].ID}, - Content: []byte(`{"units":[3]}`), - MediaType: "application/json", - Units: append([]source.SourceUnit(nil), req.Source.Units[2:]...), - }, - }, - }, nil -} - -type walkingSkeletonExtractor struct{} - -func (extractor walkingSkeletonExtractor) Key() string { - return "fake/extract" -} - -func (extractor walkingSkeletonExtractor) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (extractor walkingSkeletonExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - var response struct { - Call int `json:"call"` - } - if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ - StageName: "fake/extract", - PromptID: "fake.event", - PromptVersion: "v1", - }, &response); err != nil { - return contracts.ExtractionResult{}, err - } - - payload, err := json.Marshal(map[string]any{ - "chunk_id": req.Chunk.ID, - "llm_call": response.Call, - "text": chunkText(req.Chunk.Units), - }) - if err != nil { - return contracts.ExtractionResult{}, err - } - - return contracts.ExtractionResult{ - Output: contracts.ExtractOutput{ - Schema: contracts.ResponseSchema{ID: "fake_event", Name: "fake_event", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: payload, - MediaType: "application/json", - }, - }, - }, nil -} - -type walkingSkeletonLLMClient struct { - calls int -} - -func (client *walkingSkeletonLLMClient) CompleteStructured(ctx context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) { - client.calls++ - if response, ok := out.(*struct { - Call int `json:"call"` - }); ok { - response.Call = client.calls - } - content, err := json.Marshal(map[string]any{"call": client.calls}) - if err != nil { - return contracts.StructuredCompletionResponse{}, err - } - return contracts.StructuredCompletionResponse{ - Content: content, - }, nil -} - -type walkingSkeletonMerger struct{} - -func (merger walkingSkeletonMerger) Key() string { - return DefaultMergeModule -} - -func (merger walkingSkeletonMerger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { - outputs := make([]json.RawMessage, 0, len(req.ExtractOutputs)) - for _, output := range req.ExtractOutputs { - outputs = append(outputs, json.RawMessage(output.Payload.Content)) - } - content, err := json.Marshal(map[string]any{"outputs": outputs}) - if err != nil { - return contracts.MergeResult{}, err - } - return contracts.MergeResult{ - Output: contracts.MergeOutput{ - LaneID: req.LaneID, - SourceID: req.Source.ID, - Schema: contracts.ResponseSchema{ID: "fake_event", Name: "fake_event", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: content, - MediaType: "application/json", - }, - }, - }, nil -} - -type walkingSkeletonNormalizer struct{} - -func (normalizer walkingSkeletonNormalizer) Key() string { - return DefaultNormalizeModule -} - -func (normalizer walkingSkeletonNormalizer) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (normalizer walkingSkeletonNormalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { - var response struct { - Call int `json:"call"` - } - if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{ - StageName: "fake/normalize", - PromptID: "fake.normalize", - PromptVersion: "v1", - }, &response); err != nil { - return contracts.NormalizeResult{}, err - } - return contracts.NormalizeResult{ - Output: contracts.NormalizeOutput{ - LaneID: req.LaneID, - SourceID: req.MergeOutput.SourceID, - Schema: req.MergeOutput.Schema, - Payload: req.MergeOutput.Payload, - }, - }, nil -} - -type walkingSkeletonOutput struct{} - -func (output walkingSkeletonOutput) Key() string { - return "json" -} - -func (output walkingSkeletonOutput) Encode(ctx context.Context, req contracts.OutputRequest) (contracts.OutputResult, error) { - type rawOutput struct { - LaneID string `json:"lane_id"` - NormalizerKey string `json:"normalizer_key"` - SourceID string `json:"source_id"` - Schema contracts.ResponseSchema `json:"schema"` - MediaType string `json:"media_type"` - Content json.RawMessage `json:"content"` - } - rawOutputs := make([]rawOutput, 0, len(req.NormalizeOutputs)) - for _, output := range req.NormalizeOutputs { - rawOutputs = append(rawOutputs, rawOutput{ - LaneID: output.LaneID, - NormalizerKey: output.NormalizerKey, - SourceID: output.SourceID, - Schema: contracts.ResponseSchema{ID: output.Artifact.Schema.ID, Name: output.Artifact.Schema.Name, Version: output.Artifact.Schema.Version}, - MediaType: output.Artifact.MediaType, - Content: json.RawMessage(output.Artifact.Content), - }) - } - encoded, err := json.Marshal(struct { - Manifest artifacts.RunManifest `json:"manifest"` - NormalizeOutputs []rawOutput `json:"normalize_outputs"` - }{ - Manifest: artifacts.RunManifest{ - PipelineID: req.Manifest.PipelineID, - PipelineDigest: req.Manifest.PipelineDigest, - ArtifactLanes: req.Manifest.ArtifactLanes, - ValidationStatus: req.Manifest.ValidationStatus, - }, - NormalizeOutputs: rawOutputs, - }) - if err != nil { - return contracts.OutputResult{}, err - } - return contracts.OutputResult{ - Files: []contracts.OutputFile{ - {Name: "output.json", ContentType: "application/json", Bytes: encoded}, - }, - }, nil -} - -func readTestFixture(t *testing.T, path string) []byte { - t.Helper() - - bytes, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read fixture %q: %v", path, err) - } - return bytes -} - -func assertStructuralJSONEqual(t *testing.T, gotBytes, wantBytes []byte) { - t.Helper() - - var got any - if err := json.Unmarshal(gotBytes, &got); err != nil { - t.Fatalf("unmarshal actual JSON: %v\n%s", err, gotBytes) - } - var want any - if err := json.Unmarshal(wantBytes, &want); err != nil { - t.Fatalf("unmarshal expected JSON: %v\n%s", err, wantBytes) - } - if !reflect.DeepEqual(got, want) { - gotFormatted, _ := json.MarshalIndent(got, "", " ") - wantFormatted, _ := json.MarshalIndent(want, "", " ") - t.Fatalf("actual JSON:\n%s\nwant:\n%s", gotFormatted, wantFormatted) - } -} - -func chunkText(units []source.SourceUnit) string { - parts := make([]string, 0, len(units)) - for _, unit := range units { - parts = append(parts, unit.Text) - } - return strings.Join(parts, " ") -} - -func rawDigest(raw []byte) string { - sum := sha256.Sum256(raw) - return "sha256:" + hex.EncodeToString(sum[:]) -} diff --git a/internal/modules/dnd/codec/spells/codec.go b/internal/modules/dnd/codec/spells/codec.go index 6a4470d..03864bd 100644 --- a/internal/modules/dnd/codec/spells/codec.go +++ b/internal/modules/dnd/codec/spells/codec.go @@ -52,7 +52,7 @@ func (c *Codec) Encode(value dnd.SpellList) ([]byte, error) { } // EncodeCandidate provides the same stable representation before typed -// validators have approved a value on the temporary raw downstream path. +// validators have approved a value. func (c *Codec) EncodeCandidate(value dnd.SpellList) ([]byte, error) { content, err := json.Marshal(value) if err != nil { @@ -73,7 +73,7 @@ func (c *Codec) Decode(content []byte) (dnd.SpellList, error) { } // DecodeCandidate reads the durable representation before semantic validators -// have approved it on the temporary raw runner path. +// have approved it. func (c *Codec) DecodeCandidate(content []byte) (dnd.SpellList, error) { decoder := json.NewDecoder(bytes.NewReader(content)) decoder.DisallowUnknownFields() diff --git a/internal/modules/dnd/extract/spells/extractor.go b/internal/modules/dnd/extract/spells/extractor.go index f10da67..198d265 100644 --- a/internal/modules/dnd/extract/spells/extractor.go +++ b/internal/modules/dnd/extract/spells/extractor.go @@ -39,18 +39,6 @@ type Extractor struct { llm contracts.StructuredLLMClient } -type rawAdapter struct { - extractor *Extractor - codec RawAdapterCodec -} - -type RawAdapterCodec interface { - contracts.ArtifactCodec[dnd.SpellList] - EncodeCandidate(dnd.SpellList) ([]byte, error) -} - -var _ contracts.LegacyRawExtractor = (*rawAdapter)(nil) - func New(llmClient contracts.StructuredLLMClient, _ Options) (*Extractor, error) { if llmClient == nil { return nil, extractorErrorf("LLM client must not be nil") @@ -169,66 +157,6 @@ func Register(registry *pipeline.ExtractorRegistry) error { }) } -// RegisterWithRawAdapter keeps existing raw downstream implementations usable -// while the extractor itself produces the canonical typed artifact. -func RegisterWithRawAdapter(registry *pipeline.ExtractorRegistry, codec RawAdapterCodec) error { - if codec == nil { - return extractorErrorf("artifact codec must not be nil") - } - build := func(request pipeline.BuildRequest) (*Extractor, error) { - options, err := DecodeOptions(request.Options) - if err != nil { - return nil, err - } - return New(request.Dependencies.LLM, options) - } - return pipeline.RegisterExtractorBuilderWithRawAdapter(registry, ModuleSpec(), validateOptions, - func(request pipeline.BuildRequest) (contracts.Extractor[dnd.SpellList], error) { - return build(request) - }, - func(request pipeline.BuildRequest) (contracts.LegacyRawExtractor, error) { - extractor, err := build(request) - if err != nil { - return nil, err - } - return &rawAdapter{extractor: extractor, codec: codec}, nil - }, - ) -} - -func (adapter *rawAdapter) Key() string { return Key } - -func (adapter *rawAdapter) ReferenceSlots() []contracts.ReferenceSlot { - return adapter.extractor.ReferenceSlots() -} - -func (adapter *rawAdapter) ManifestMetadata() map[string]any { - return adapter.extractor.ManifestMetadata() -} - -func (adapter *rawAdapter) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - result, err := adapter.extractor.Extract(ctx, contracts.TypedExtractionRequest{ - Source: req.Source, Chunk: req.Chunk, AmbientContext: req.AmbientContext, - SourceInput: req.SourceInput, SessionID: req.SessionID, References: req.References, - LLMProfile: req.LLMProfile, Metadata: req.Metadata, - }) - if err != nil { - return contracts.ExtractionResult{}, err - } - content, err := adapter.codec.EncodeCandidate(result.Value) - if err != nil { - return contracts.ExtractionResult{}, extractorErrorf("encode canonical output: %w", err) - } - schema := adapter.codec.Schema() - return contracts.ExtractionResult{ - Output: contracts.ExtractOutput{ - Schema: contracts.ResponseSchema{ID: schema.ID, Name: schema.Name, Version: schema.Version, JSONSchema: append([]byte(nil), schema.JSONSchema...)}, - Payload: contracts.RawPayload{Content: content, MediaType: adapter.codec.MediaType(), Metadata: map[string]any{"spell_cast_count": len(result.Value.SpellCasts)}}, - }, - Warnings: result.Warnings, - }, nil -} - func validateOptions(options map[string]any) error { _, err := DecodeOptions(options) return err diff --git a/internal/modules/dnd/extract/spells/registry_test.go b/internal/modules/dnd/extract/spells/registry_test.go index b9eb40e..afe00ff 100644 --- a/internal/modules/dnd/extract/spells/registry_test.go +++ b/internal/modules/dnd/extract/spells/registry_test.go @@ -76,9 +76,6 @@ func TestRegisterMakesExtractorBuildable(t *testing.T) { t.Fatalf("Register() error = %v, want nil", err) } - if _, err := registry.BuildLegacyRaw(Key); err == nil || !strings.Contains(err.Error(), "legacy raw") { - t.Fatalf("BuildLegacyRaw() error = %v, want typed registration error", err) - } if _, err := DecodeOptions(map[string]any{"unexpected": true}); err == nil || !strings.Contains(err.Error(), "unknown option") { t.Fatalf("DecodeOptions() error = %v, want unknown option error", err) } diff --git a/internal/modules/dnd/validate/spells/shape/validator.go b/internal/modules/dnd/validate/spells/shape/validator.go index 2de7cc7..52825ab 100644 --- a/internal/modules/dnd/validate/spells/shape/validator.go +++ b/internal/modules/dnd/validate/spells/shape/validator.go @@ -15,10 +15,6 @@ const ReasonCode = "invalid_spell_shape" type Options struct{} type Validator struct{} -type legacyValidator struct{ codec decoder } -type decoder interface { - DecodeCandidate([]byte) (dnd.SpellList, error) -} var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) @@ -58,18 +54,6 @@ func Validate(value dnd.SpellList) error { return nil } -func (v *legacyValidator) Name() string { return Key } -func (v *legacyValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} -func (v *legacyValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - value, err := v.codec.DecodeCandidate(req.Payload.Content) - if err != nil { - return rejection(err.Error()), nil - } - return New(Options{}).Validate(ctx, contracts.TypedValidationRequest[dnd.SpellList]{Value: value}) -} - func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} } @@ -82,17 +66,6 @@ func Register(registry *pipeline.ValidatorRegistry) error { return New(options), nil }) } -func RegisterLegacy(registry *pipeline.ValidatorRegistry, codec decoder) error { - if codec == nil { - return fmt.Errorf("spell shape validator codec must not be nil") - } - return registry.RegisterLegacyRawBuilderWithSpec(Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.LegacyRawValidator, error) { - if _, err := DecodeOptions(request.Options); err != nil { - return nil, err - } - return &legacyValidator{codec: codec}, nil - }) -} func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, err diff --git a/internal/modules/dnd/validate/spells/source_refs/validator.go b/internal/modules/dnd/validate/spells/source_refs/validator.go index af0e768..2a1f109 100644 --- a/internal/modules/dnd/validate/spells/source_refs/validator.go +++ b/internal/modules/dnd/validate/spells/source_refs/validator.go @@ -16,10 +16,6 @@ const ReasonCode = "invalid_source_refs" type Options struct{} type Validator struct{} -type legacyValidator struct{ codec decoder } -type decoder interface { - DecodeCandidate([]byte) (dnd.SpellList, error) -} var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) @@ -41,20 +37,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } return contracts.ValidationResult{Approved: true}, nil } -func (v *legacyValidator) Name() string { return Key } -func (v *legacyValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} -func (v *legacyValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - value, err := v.codec.DecodeCandidate(req.Payload.Content) - if err != nil { - return rejection(err.Error()), nil - } - if err := spellshape.Validate(value); err != nil { - return rejection(err.Error()), nil - } - return New(Options{}).Validate(ctx, contracts.TypedValidationRequest[dnd.SpellList]{Source: req.Source, Value: value}) -} func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} } @@ -67,17 +49,6 @@ func Register(registry *pipeline.ValidatorRegistry) error { return New(options), nil }) } -func RegisterLegacy(registry *pipeline.ValidatorRegistry, codec decoder) error { - if codec == nil { - return fmt.Errorf("spell source references validator codec must not be nil") - } - return registry.RegisterLegacyRawBuilderWithSpec(Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.LegacyRawValidator, error) { - if _, err := DecodeOptions(request.Options); err != nil { - return nil, err - } - return &legacyValidator{codec: codec}, nil - }) -} func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, err diff --git a/internal/modules/dnd/validate/spells/source_relatedness/validator.go b/internal/modules/dnd/validate/spells/source_relatedness/validator.go index a756bcf..b4cfc02 100644 --- a/internal/modules/dnd/validate/spells/source_relatedness/validator.go +++ b/internal/modules/dnd/validate/spells/source_relatedness/validator.go @@ -17,10 +17,6 @@ const WarningReasonCode = "spell_not_near_source" type Options struct{} type Validator struct{} -type legacyValidator struct{ codec decoder } -type decoder interface { - DecodeCandidate([]byte) (dnd.SpellList, error) -} var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil) @@ -41,20 +37,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq } return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil } -func (v *legacyValidator) Name() string { return Key } -func (v *legacyValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} -func (v *legacyValidator) Validate(ctx context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - value, err := v.codec.DecodeCandidate(req.Payload.Content) - if err != nil { - return contracts.ValidationResult{Approved: true}, nil - } - if err := spellshape.Validate(value); err != nil { - return contracts.ValidationResult{Approved: true}, nil - } - return New(Options{}).Validate(ctx, contracts.TypedValidationRequest[dnd.SpellList]{Source: req.Source, Value: value}) -} func spellAppearsInCitedText(doc *source.SourceDocument, spell dnd.SpellCast) bool { name := strings.ToLower(strings.TrimSpace(spell.Spell)) if name == "" { @@ -100,17 +82,6 @@ func Register(registry *pipeline.ValidatorRegistry) error { return New(options), nil }) } -func RegisterLegacy(registry *pipeline.ValidatorRegistry, codec decoder) error { - if codec == nil { - return fmt.Errorf("spell source relatedness validator codec must not be nil") - } - return registry.RegisterLegacyRawBuilderWithSpec(Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.LegacyRawValidator, error) { - if _, err := DecodeOptions(request.Options); err != nil { - return nil, err - } - return &legacyValidator{codec: codec}, nil - }) -} func DecodeOptions(options map[string]any) (Options, error) { if err := pipeline.RejectUnknownOptions(options); err != nil { return Options{}, err diff --git a/internal/modules/generic/merge/appendorder/merger.go b/internal/modules/generic/merge/appendorder/merger.go index 0e86675..729d4f3 100644 --- a/internal/modules/generic/merge/appendorder/merger.go +++ b/internal/modules/generic/merge/appendorder/merger.go @@ -1,234 +1,15 @@ package appendorder import ( - "context" - "encoding/json" "fmt" - "mime" - "sort" - "strings" - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) const Key = "appendorder" -var _ contracts.LegacyRawMerger = (*Merger)(nil) - -type Merger struct{} - -func New() *Merger { - return &Merger{} -} - -func (m *Merger) Key() string { - return Key -} - -func (m *Merger) Merge(ctx context.Context, req contracts.MergeRequest) (contracts.MergeResult, error) { - if m == nil { - return contracts.MergeResult{}, mergerErrorf("merger must not be nil") - } - if ctx == nil { - return contracts.MergeResult{}, mergerErrorf("context must not be nil") - } - if err := ctx.Err(); err != nil { - return contracts.MergeResult{}, mergerErrorf("context error before merge: %w", err) - } - - outputs, err := orderedOutputs(req.ExtractOutputs) - if err != nil { - return contracts.MergeResult{}, err - } - if len(outputs) == 1 { - payload := cloneRawPayload(outputs[0].Payload) - return contracts.MergeResult{ - Output: contracts.MergeOutput{ - LaneID: req.LaneID, - MergerKey: Key, - SourceID: outputs[0].SourceID, - Schema: outputs[0].Schema, - Payload: payload, - }, - }, nil - } - - content, err := mergedContent(outputs) - if err != nil { - return contracts.MergeResult{}, err - } - return contracts.MergeResult{ - Output: contracts.MergeOutput{ - LaneID: req.LaneID, - MergerKey: Key, - SourceID: sourceID(outputs), - Schema: commonSchema(outputs), - Payload: contracts.RawPayload{ - Content: content, - MediaType: "application/json", - }, - }, - }, nil -} - func ModuleSpec() pipeline.ModuleSpec { - return pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageMerge, - Provides: []string{"merged"}, - } -} - -func Register(registry *pipeline.MergerRegistry) error { - return registry.RegisterLegacyRawWithSpec(ModuleSpec(), func() (contracts.LegacyRawMerger, error) { - return New(), nil - }) -} - -func orderedOutputs(outputs []contracts.ExtractOutput) ([]contracts.ExtractOutput, error) { - ordered := make([]contracts.ExtractOutput, 0, len(outputs)) - for _, output := range outputs { - if !isJSONMediaType(output.Payload.MediaType) { - return nil, mergerErrorf("extract output for chunk %q has unsupported media type %q", output.ChunkID, output.Payload.MediaType) - } - if !json.Valid(output.Payload.Content) { - return nil, mergerErrorf("extract output for chunk %q contains invalid JSON", output.ChunkID) - } - ordered = append(ordered, cloneExtractOutput(output)) - } - sort.SliceStable(ordered, func(i, j int) bool { - return ordered[i].ChunkIndex < ordered[j].ChunkIndex - }) - return ordered, nil -} - -func mergedContent(outputs []contracts.ExtractOutput) ([]byte, error) { - values := make([]any, 0, len(outputs)) - objects := make([]map[string]any, 0, len(outputs)) - for _, output := range outputs { - var value any - if err := json.Unmarshal(output.Payload.Content, &value); err != nil { - return nil, mergerErrorf("decode extract output for chunk %q: %w", output.ChunkID, err) - } - values = append(values, value) - object, ok := value.(map[string]any) - if !ok { - continue - } - objects = append(objects, object) - } - - if len(objects) == len(outputs) { - if field, ok := commonArrayField(objects); ok { - merged := make([]any, 0) - for _, object := range objects { - items := object[field].([]any) - merged = append(merged, items...) - } - return marshalMerged(map[string]any{field: merged}) - } - } - return marshalMerged(values) -} - -func commonArrayField(objects []map[string]any) (string, bool) { - if len(objects) == 0 { - return "", false - } - candidates := map[string]struct{}{} - for key, value := range objects[0] { - if _, ok := value.([]any); ok { - candidates[key] = struct{}{} - } - } - for _, object := range objects[1:] { - for key := range candidates { - if _, ok := object[key].([]any); !ok { - delete(candidates, key) - } - } - } - if len(candidates) != 1 { - return "", false - } - for key := range candidates { - return key, true - } - return "", false -} - -func marshalMerged(value any) ([]byte, error) { - content, err := json.Marshal(value) - if err != nil { - return nil, mergerErrorf("encode merged output: %w", err) - } - return content, nil -} - -func isJSONMediaType(mediaType string) bool { - base, _, err := mime.ParseMediaType(strings.TrimSpace(mediaType)) - if err != nil { - base = strings.TrimSpace(mediaType) - } - return strings.EqualFold(base, "application/json") -} - -func sourceID(outputs []contracts.ExtractOutput) string { - for _, output := range outputs { - if output.SourceID != "" { - return output.SourceID - } - } - return "" -} - -func commonSchema(outputs []contracts.ExtractOutput) contracts.ResponseSchema { - if len(outputs) == 0 { - return contracts.ResponseSchema{} - } - schema := outputs[0].Schema - for _, output := range outputs[1:] { - if !sameResponseSchema(output.Schema, schema) { - return contracts.ResponseSchema{} - } - } - return schema -} - -func sameResponseSchema(left contracts.ResponseSchema, right contracts.ResponseSchema) bool { - return left.ID == right.ID && left.Name == right.Name && left.Version == right.Version && string(left.JSONSchema) == string(right.JSONSchema) -} - -func cloneExtractOutput(output contracts.ExtractOutput) contracts.ExtractOutput { - output.Schema = cloneResponseSchema(output.Schema) - output.Payload = cloneRawPayload(output.Payload) - return output -} - -func cloneResponseSchema(schema contracts.ResponseSchema) contracts.ResponseSchema { - schema.JSONSchema = append([]byte(nil), schema.JSONSchema...) - return schema -} - -func cloneRawPayload(payload contracts.RawPayload) contracts.RawPayload { - return contracts.RawPayload{ - Content: append([]byte(nil), payload.Content...), - MediaType: payload.MediaType, - Metadata: cloneMetadata(payload.Metadata), - Warnings: append([]contracts.Warning(nil), payload.Warnings...), - } -} - -func cloneMetadata(metadata map[string]any) map[string]any { - if len(metadata) == 0 { - return nil - } - out := make(map[string]any, len(metadata)) - for key, value := range metadata { - out[key] = value - } - return out + return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageMerge, Provides: []string{"merged"}} } func mergerErrorf(format string, args ...any) error { diff --git a/internal/modules/generic/merge/appendorder/merger_test.go b/internal/modules/generic/merge/appendorder/merger_test.go deleted file mode 100644 index abdad42..0000000 --- a/internal/modules/generic/merge/appendorder/merger_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package appendorder - -import ( - "context" - "encoding/json" - "reflect" - "strings" - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" - "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" -) - -func TestModuleSpecAndRegister(t *testing.T) { - want := pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageMerge, - Provides: []string{"merged"}, - } - if got := ModuleSpec(); !reflect.DeepEqual(got, want) { - t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) - } - - registry := pipeline.NewMergerRegistry() - if err := Register(registry); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - spec, ok := registry.Spec(Key) - if !ok { - t.Fatalf("Spec(%q) ok = false, want true", Key) - } - if !reflect.DeepEqual(spec, want) { - t.Fatalf("registered spec = %#v, want %#v", spec, want) - } -} - -func TestMergePassesThroughSingleExtractOutput(t *testing.T) { - input := extractOutput("chunk-0", 0, `{"name":"original"}`) - - result, err := New().Merge(context.Background(), contracts.MergeRequest{ - LaneID: "events", - ExtractOutputs: []contracts.ExtractOutput{input}, - }) - if err != nil { - t.Fatalf("Merge() error = %v, want nil", err) - } - - if result.Output.LaneID != "events" || result.Output.MergerKey != Key { - t.Fatalf("output provenance = %#v, want lane and merger", result.Output) - } - if string(result.Output.Payload.Content) != `{"name":"original"}` { - t.Fatalf("content = %s, want original content", result.Output.Payload.Content) - } - if result.Output.Payload.Metadata["name"] != "chunk-0" { - t.Fatalf("metadata = %#v, want original metadata", result.Output.Payload.Metadata) - } -} - -func TestMergeDefensivelyCopiesRawPayload(t *testing.T) { - input := extractOutput("chunk-0", 0, `{"name":"original"}`) - - result, err := New().Merge(context.Background(), contracts.MergeRequest{ - LaneID: "events", - ExtractOutputs: []contracts.ExtractOutput{input}, - }) - if err != nil { - t.Fatalf("Merge() error = %v, want nil", err) - } - - input.Payload.Content[0] = '[' - input.Payload.Metadata["name"] = "changed" - - if string(result.Output.Payload.Content) != `{"name":"original"}` { - t.Fatalf("content changed after input mutation: %s", result.Output.Payload.Content) - } - if result.Output.Payload.Metadata["name"] != "chunk-0" { - t.Fatalf("metadata changed after input mutation: %#v", result.Output.Payload.Metadata) - } -} - -func TestMergeConcatenatesCommonTopLevelArrayFieldInChunkOrder(t *testing.T) { - result, err := New().Merge(context.Background(), contracts.MergeRequest{ - LaneID: "events", - ExtractOutputs: []contracts.ExtractOutput{ - extractOutput("chunk-1", 1, `{"events":[{"name":"second"}]}`), - extractOutput("chunk-0", 0, `{"events":[{"name":"first"}]}`), - }, - }) - if err != nil { - t.Fatalf("Merge() error = %v, want nil", err) - } - if result.Output.Payload.MediaType != "application/json" { - t.Fatalf("MediaType = %q, want application/json", result.Output.Payload.MediaType) - } - - var decoded struct { - Events []struct { - Name string `json:"name"` - } `json:"events"` - } - if err := json.Unmarshal(result.Output.Payload.Content, &decoded); err != nil { - t.Fatalf("Unmarshal() error = %v, want nil", err) - } - if len(decoded.Events) != 2 || decoded.Events[0].Name != "first" || decoded.Events[1].Name != "second" { - t.Fatalf("events = %#v, want concatenated chunk order", decoded.Events) - } - if result.Output.Schema.ID != "schema-id" { - t.Fatalf("schema = %#v, want common extract schema", result.Output.Schema) - } -} - -func TestMergeFallsBackToOrderedJSONValueArrayWhenShapesDiffer(t *testing.T) { - result, err := New().Merge(context.Background(), contracts.MergeRequest{ - LaneID: "events", - ExtractOutputs: []contracts.ExtractOutput{ - extractOutput("chunk-1", 1, `{"notes":["second"]}`), - extractOutput("chunk-0", 0, `{"events":[{"name":"first"}]}`), - }, - }) - if err != nil { - t.Fatalf("Merge() error = %v, want nil", err) - } - - var decoded []map[string]any - if err := json.Unmarshal(result.Output.Payload.Content, &decoded); err != nil { - t.Fatalf("Unmarshal() error = %v, want nil", err) - } - if len(decoded) != 2 { - t.Fatalf("len(decoded) = %d, want 2", len(decoded)) - } - if _, ok := decoded[0]["events"]; !ok { - t.Fatalf("decoded[0] = %#v, want first chunk value", decoded[0]) - } - if _, ok := decoded[1]["notes"]; !ok { - t.Fatalf("decoded[1] = %#v, want second chunk value", decoded[1]) - } -} - -func TestMergeRejectsInvalidJSONAndNonJSONMediaTypes(t *testing.T) { - tests := []struct { - name string - output contracts.ExtractOutput - want string - }{ - { - name: "invalid JSON", - output: extractOutput("chunk-0", 0, `{"events":[`), - want: "invalid JSON", - }, - { - name: "non JSON media type", - output: func() contracts.ExtractOutput { - output := extractOutput("chunk-0", 0, `{"events":[]}`) - output.Payload.MediaType = "text/plain" - return output - }(), - want: "unsupported media type", - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - _, err := New().Merge(context.Background(), contracts.MergeRequest{ - LaneID: "events", - ExtractOutputs: []contracts.ExtractOutput{test.output}, - }) - if err == nil { - t.Fatal("Merge() error = nil, want error") - } - if !strings.Contains(err.Error(), test.want) { - t.Fatalf("Merge() error = %q, want %q", err.Error(), test.want) - } - }) - } -} - -func TestTypedMergeUsesRequestOrderForReusableValueType(t *testing.T) { - type notes struct{ Values []string } - merger, err := NewTyped(func(values []notes) (notes, error) { - var combined notes - for _, value := range values { - combined.Values = append(combined.Values, value.Values...) - } - return combined, nil - }) - if err != nil { - t.Fatalf("NewTyped() error = %v", err) - } - result, err := merger.Merge(context.Background(), contracts.TypedMergeRequest[notes]{ExtractOutputs: []contracts.ExtractArtifact[notes]{ - {ChunkIndex: 4, Value: notes{Values: []string{"first"}}}, - {ChunkIndex: 1, Value: notes{Values: []string{"second"}}}, - }}) - if err != nil { - t.Fatalf("Merge() error = %v", err) - } - if got := result.Value.Values; !reflect.DeepEqual(got, []string{"first", "second"}) { - t.Fatalf("Values = %#v", got) - } -} - -func extractOutput(chunkID string, chunkIndex int, content string) contracts.ExtractOutput { - return contracts.ExtractOutput{ - LaneID: "events", - ExtractorKey: "extract", - SourceID: "source-1", - ChunkID: chunkID, - ChunkIndex: chunkIndex, - Schema: contracts.ResponseSchema{ID: "schema-id", Name: "schema-name", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(content), - MediaType: "application/json", - Metadata: map[string]any{"name": chunkID}, - }, - } -} diff --git a/internal/modules/generic/merge/appendorder/typed_test.go b/internal/modules/generic/merge/appendorder/typed_test.go new file mode 100644 index 0000000..091c07d --- /dev/null +++ b/internal/modules/generic/merge/appendorder/typed_test.go @@ -0,0 +1,25 @@ +package appendorder + +import ( + "context" + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestTypedMergerCombinesValuesInFrameworkOrder(t *testing.T) { + merger, err := NewTyped(func(values []string) (string, error) { + if !reflect.DeepEqual(values, []string{"first", "second"}) { + t.Fatalf("values=%#v", values) + } + return values[0] + values[1], nil + }) + if err != nil { + t.Fatal(err) + } + result, err := merger.Merge(context.Background(), contracts.TypedMergeRequest[string]{ExtractOutputs: []contracts.ExtractArtifact[string]{{ChunkIndex: 0, Value: "first"}, {ChunkIndex: 1, Value: "second"}}}) + if err != nil || result.Value != "firstsecond" { + t.Fatalf("result=%#v err=%v", result, err) + } +} diff --git a/internal/modules/generic/normalize/noop/normalizer.go b/internal/modules/generic/normalize/noop/normalizer.go index 4cff25c..ebe416d 100644 --- a/internal/modules/generic/normalize/noop/normalizer.go +++ b/internal/modules/generic/normalize/noop/normalizer.go @@ -1,85 +1,15 @@ package noop import ( - "context" "fmt" - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" ) const Key = "noop" -var _ contracts.LegacyRawNormalizer = (*Normalizer)(nil) - -type Normalizer struct{} - -func New() *Normalizer { - return &Normalizer{} -} - -func (n *Normalizer) Key() string { - return Key -} - -func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { - return nil -} - -func (n *Normalizer) Normalize(ctx context.Context, req contracts.NormalizeRequest) (contracts.NormalizeResult, error) { - if n == nil { - return contracts.NormalizeResult{}, normalizerErrorf("normalizer must not be nil") - } - if ctx == nil { - return contracts.NormalizeResult{}, normalizerErrorf("context must not be nil") - } - if err := ctx.Err(); err != nil { - return contracts.NormalizeResult{}, normalizerErrorf("context error before normalize: %w", err) - } - return contracts.NormalizeResult{ - Output: contracts.NormalizeOutput{ - LaneID: req.LaneID, - NormalizerKey: Key, - SourceID: req.MergeOutput.SourceID, - Schema: req.MergeOutput.Schema, - Payload: cloneRawPayload(req.MergeOutput.Payload), - }, - }, nil -} - func ModuleSpec() pipeline.ModuleSpec { - return pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageNormalize, - Requires: []string{"merged"}, - Provides: []string{"normalized"}, - } -} - -func Register(registry *pipeline.NormalizerRegistry) error { - return registry.RegisterLegacyRawWithSpec(ModuleSpec(), func() (contracts.LegacyRawNormalizer, error) { - return New(), nil - }) -} - -func cloneRawPayload(payload contracts.RawPayload) contracts.RawPayload { - return contracts.RawPayload{ - Content: append([]byte(nil), payload.Content...), - MediaType: payload.MediaType, - Metadata: cloneMetadata(payload.Metadata), - Warnings: append([]contracts.Warning(nil), payload.Warnings...), - } -} - -func cloneMetadata(metadata map[string]any) map[string]any { - if len(metadata) == 0 { - return nil - } - out := make(map[string]any, len(metadata)) - for key, value := range metadata { - out[key] = value - } - return out + return pipeline.ModuleSpec{Key: Key, Stage: pipeline.StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}} } func normalizerErrorf(format string, args ...any) error { diff --git a/internal/modules/generic/normalize/noop/normalizer_test.go b/internal/modules/generic/normalize/noop/normalizer_test.go deleted file mode 100644 index bab10dc..0000000 --- a/internal/modules/generic/normalize/noop/normalizer_test.go +++ /dev/null @@ -1,112 +0,0 @@ -package noop - -import ( - "context" - "reflect" - "testing" - - "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" - "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" -) - -func TestModuleSpecAndRegister(t *testing.T) { - want := pipeline.ModuleSpec{ - Key: Key, - Stage: pipeline.StageNormalize, - Requires: []string{"merged"}, - Provides: []string{"normalized"}, - } - if got := ModuleSpec(); !reflect.DeepEqual(got, want) { - t.Fatalf("ModuleSpec() = %#v, want %#v", got, want) - } - - registry := pipeline.NewNormalizerRegistry() - if err := Register(registry); err != nil { - t.Fatalf("Register() error = %v, want nil", err) - } - spec, ok := registry.Spec(Key) - if !ok { - t.Fatalf("Spec(%q) ok = false, want true", Key) - } - if !reflect.DeepEqual(spec, want) { - t.Fatalf("registered spec = %#v, want %#v", spec, want) - } - normalizer, err := registry.BuildLegacyRaw(Key) - if err != nil { - t.Fatalf("Build(%q) error = %v, want nil", Key, err) - } - if slots := normalizer.ReferenceSlots(); len(slots) != 0 { - t.Fatalf("ReferenceSlots() = %#v, want none", slots) - } -} - -func TestNormalizePassesThroughMergeOutput(t *testing.T) { - input := mergeOutput(`{"name":"original"}`) - - result, err := New().Normalize(context.Background(), contracts.NormalizeRequest{ - LaneID: "events", - MergeOutput: input, - }) - if err != nil { - t.Fatalf("Normalize() error = %v, want nil", err) - } - - if result.Output.LaneID != "events" || result.Output.NormalizerKey != Key { - t.Fatalf("output provenance = %#v, want lane and normalizer", result.Output) - } - if string(result.Output.Payload.Content) != `{"name":"original"}` { - t.Fatalf("content = %s, want original content", result.Output.Payload.Content) - } - if result.Output.Payload.Metadata["name"] != "original" { - t.Fatalf("metadata = %#v, want original metadata", result.Output.Payload.Metadata) - } -} - -func TestNormalizeDefensivelyCopiesRawPayload(t *testing.T) { - input := mergeOutput(`{"name":"original"}`) - - result, err := New().Normalize(context.Background(), contracts.NormalizeRequest{ - LaneID: "events", - MergeOutput: input, - }) - if err != nil { - t.Fatalf("Normalize() error = %v, want nil", err) - } - - input.Payload.Content[0] = '[' - input.Payload.Metadata["name"] = "changed" - - if string(result.Output.Payload.Content) != `{"name":"original"}` { - t.Fatalf("content changed after input mutation: %s", result.Output.Payload.Content) - } - if result.Output.Payload.Metadata["name"] != "original" { - t.Fatalf("metadata changed after input mutation: %#v", result.Output.Payload.Metadata) - } -} - -func TestTypedNormalizePassesThroughReusableValueType(t *testing.T) { - type score struct{ Value int } - result, err := NewTyped[score]().Normalize(context.Background(), contracts.TypedNormalizeRequest[score]{ - MergeOutput: contracts.MergeArtifact[score]{Value: score{Value: 7}}, - }) - if err != nil { - t.Fatalf("Normalize() error = %v", err) - } - if result.Value.Value != 7 { - t.Fatalf("Value = %d, want 7", result.Value.Value) - } -} - -func mergeOutput(content string) contracts.MergeOutput { - return contracts.MergeOutput{ - LaneID: "events", - MergerKey: "merge", - SourceID: "source-1", - Schema: contracts.ResponseSchema{ID: "schema-id", Name: "schema-name", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: []byte(content), - MediaType: "application/json", - Metadata: map[string]any{"name": "original"}, - }, - } -} diff --git a/internal/modules/generic/normalize/noop/typed_test.go b/internal/modules/generic/normalize/noop/typed_test.go new file mode 100644 index 0000000..52141b4 --- /dev/null +++ b/internal/modules/generic/normalize/noop/typed_test.go @@ -0,0 +1,16 @@ +package noop + +import ( + "context" + "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" +) + +func TestTypedNormalizerPreservesValue(t *testing.T) { + normalizer := NewTyped[string]() + result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[string]{MergeOutput: contracts.MergeArtifact[string]{Value: "value"}}) + if err != nil || result.Value != "value" { + t.Fatalf("result=%#v err=%v", result, err) + } +} diff --git a/internal/modules/generic/output/json/encoder.go b/internal/modules/generic/output/json/encoder.go index 17d7498..d563280 100644 --- a/internal/modules/generic/output/json/encoder.go +++ b/internal/modules/generic/output/json/encoder.go @@ -253,15 +253,6 @@ func cloneNormalizeOutputs(outputs []contracts.SerializedOutput) []contracts.Ser return out } -func cloneRawPayload(payload contracts.RawPayload) contracts.RawPayload { - return contracts.RawPayload{ - Content: append([]byte(nil), payload.Content...), - MediaType: payload.MediaType, - Metadata: cloneMetadata(payload.Metadata), - Warnings: append([]contracts.Warning(nil), payload.Warnings...), - } -} - func cloneRejected(rejected []contracts.RejectedOutput) []contracts.RejectedOutput { if len(rejected) == 0 { return []contracts.RejectedOutput{} diff --git a/internal/modules/generic/register/register.go b/internal/modules/generic/register/register.go index f5be11d..f9fdabc 100644 --- a/internal/modules/generic/register/register.go +++ b/internal/modules/generic/register/register.go @@ -7,8 +7,6 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/chunk/units" - "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder" - "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/output/json" alwaysaccept "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_accept" alwaysreject "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_reject" @@ -27,8 +25,6 @@ func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error { register func() error }{ {name: "generic chunker", register: func() error { return units.Register(registries.Chunkers) }}, - {name: "appendorder merger", register: func() error { return appendorder.Register(registries.Mergers) }}, - {name: "noop normalizer", register: func() error { return noop.Register(registries.Normalizers) }}, {name: "always accept validator", register: func() error { return alwaysaccept.Register(registries.Validators) }}, {name: "always reject validator", register: func() error { return alwaysreject.Register(registries.Validators) }}, {name: "valid json validator", register: func() error { return validjson.Register(registries.Validators) }}, diff --git a/internal/modules/generic/register/register_test.go b/internal/modules/generic/register/register_test.go index 93035d1..6a34e4d 100644 --- a/internal/modules/generic/register/register_test.go +++ b/internal/modules/generic/register/register_test.go @@ -14,8 +14,8 @@ func TestRegisterAddsGenericFamily(t *testing.T) { t.Fatalf("Register() error = %v, want nil", err) } assertKeys(t, "chunkers", registries.Chunkers.RegisteredKeys(), []string{"generic"}) - assertKeys(t, "mergers", registries.Mergers.RegisteredKeys(), []string{"appendorder"}) - assertKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), []string{"noop"}) + assertKeys(t, "mergers", registries.Mergers.RegisteredKeys(), nil) + assertKeys(t, "normalizers", registries.Normalizers.RegisteredKeys(), nil) assertKeys(t, "validators", registries.Validators.RegisteredKeys(), []string{ "generic/always_accept", "generic/always_reject", diff --git a/internal/modules/generic/validate/always_accept/validator.go b/internal/modules/generic/validate/always_accept/validator.go index 2d8ab8b..4b1b1b0 100644 --- a/internal/modules/generic/validate/always_accept/validator.go +++ b/internal/modules/generic/validate/always_accept/validator.go @@ -12,7 +12,6 @@ const Key = "generic/always_accept" type Options struct{} type ChunkValidator struct{} type TypedValidator[T any] struct{} -type legacyValidator struct{} var _ contracts.ChunkValidator = (*ChunkValidator)(nil) @@ -35,33 +34,17 @@ func (v *TypedValidator[T]) Validate(context.Context, contracts.TypedValidationR return contracts.ValidationResult{Approved: true}, nil } -func (v *legacyValidator) Name() string { return Key } -func (v *legacyValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} -func (v *legacyValidator) Validate(context.Context, contracts.ValidationRequest) (contracts.ValidationResult, error) { - return contracts.ValidationResult{Approved: true}, nil -} - func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} } func Register(registry *pipeline.ValidatorRegistry) error { - if err := pipeline.RegisterChunkValidatorBuilder(registry, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.ChunkValidator, error) { + return pipeline.RegisterChunkValidatorBuilder(registry, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.ChunkValidator, error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return NewChunk(options), nil - }); err != nil { - return err - } - return registry.RegisterLegacyRawBuilderWithSpec(Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.LegacyRawValidator, error) { - if _, err := DecodeOptions(request.Options); err != nil { - return nil, err - } - return &legacyValidator{}, nil }) } diff --git a/internal/modules/generic/validate/always_accept/validator_test.go b/internal/modules/generic/validate/always_accept/validator_test.go index 7f9d069..a339768 100644 --- a/internal/modules/generic/validate/always_accept/validator_test.go +++ b/internal/modules/generic/validate/always_accept/validator_test.go @@ -30,11 +30,7 @@ func TestSpecAndRegister(t *testing.T) { if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } - validator, err := registry.BuildLegacyRaw(Key) - if err != nil { - t.Fatalf("Build(%q) error = %v, want nil", Key, err) - } - if validator.Name() != Key { - t.Fatalf("Name() = %q, want %q", validator.Name(), Key) + if registered, ok := registry.Spec(Key); !ok || registered.Key != Key { + t.Fatalf("Spec(%q) = %#v, %v", Key, registered, ok) } } diff --git a/internal/modules/generic/validate/always_reject/validator.go b/internal/modules/generic/validate/always_reject/validator.go index 5fa0478..342f451 100644 --- a/internal/modules/generic/validate/always_reject/validator.go +++ b/internal/modules/generic/validate/always_reject/validator.go @@ -13,7 +13,6 @@ const ReasonCode = "always_reject" type Options struct{} type ChunkValidator struct{} type TypedValidator[T any] struct{} -type legacyValidator struct{} func NewChunk(Options) *ChunkValidator { return &ChunkValidator{} } func NewTyped[T any](Options) *TypedValidator[T] { return &TypedValidator[T]{} } @@ -35,32 +34,16 @@ func (v *TypedValidator[T]) ExecutionClass() contracts.ExecutionClass { func (v *TypedValidator[T]) Validate(context.Context, contracts.TypedValidationRequest[T]) (contracts.ValidationResult, error) { return rejection(), nil } -func (v *legacyValidator) Name() string { return Key } -func (v *legacyValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} -func (v *legacyValidator) Validate(context.Context, contracts.ValidationRequest) (contracts.ValidationResult, error) { - return rejection(), nil -} - func Spec() pipeline.ValidatorSpec { return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic} } func Register(registry *pipeline.ValidatorRegistry) error { - if err := pipeline.RegisterChunkValidatorBuilder(registry, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.ChunkValidator, error) { + return pipeline.RegisterChunkValidatorBuilder(registry, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.ChunkValidator, error) { options, err := DecodeOptions(request.Options) if err != nil { return nil, err } return NewChunk(options), nil - }); err != nil { - return err - } - return registry.RegisterLegacyRawBuilderWithSpec(Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.LegacyRawValidator, error) { - if _, err := DecodeOptions(request.Options); err != nil { - return nil, err - } - return &legacyValidator{}, nil }) } func RegisterTyped[T any](registry *pipeline.ValidatorRegistry, kind contracts.ArtifactKind) error { diff --git a/internal/modules/generic/validate/always_reject/validator_test.go b/internal/modules/generic/validate/always_reject/validator_test.go index 104065f..fe9d7aa 100644 --- a/internal/modules/generic/validate/always_reject/validator_test.go +++ b/internal/modules/generic/validate/always_reject/validator_test.go @@ -33,11 +33,7 @@ func TestSpecAndRegister(t *testing.T) { if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } - validator, err := registry.BuildLegacyRaw(Key) - if err != nil { - t.Fatalf("Build(%q) error = %v, want nil", Key, err) - } - if validator.Name() != Key { - t.Fatalf("Name() = %q, want %q", validator.Name(), Key) + if registered, ok := registry.Spec(Key); !ok || registered.Key != Key { + t.Fatalf("Spec(%q) = %#v, %v", Key, registered, ok) } } diff --git a/internal/modules/generic/validate/valid_json/validator.go b/internal/modules/generic/validate/valid_json/validator.go index 6c200a1..9133217 100644 --- a/internal/modules/generic/validate/valid_json/validator.go +++ b/internal/modules/generic/validate/valid_json/validator.go @@ -15,10 +15,7 @@ type Options struct{} type Validator struct{} -type legacyValidator struct{} - var _ contracts.SerializedValidator = (*Validator)(nil) -var _ contracts.LegacyRawValidator = (*legacyValidator)(nil) func New(Options) *Validator { return &Validator{} } @@ -32,16 +29,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.SerializedValidati return validate(req.Content), nil } -func (v *legacyValidator) Name() string { return Key } - -func (v *legacyValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} - -func (v *legacyValidator) Validate(_ context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - return validate(req.Payload.Content), nil -} - func validate(content []byte) contracts.ValidationResult { if !json.Valid(content) { return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCodeInvalidJSON, Message: "payload is not valid JSON"} @@ -54,7 +41,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - if err := pipeline.RegisterSerializedValidatorBuilder(registry, pipeline.SerializedValidatorSpec{ + return pipeline.RegisterSerializedValidatorBuilder(registry, pipeline.SerializedValidatorSpec{ ValidatorSpec: Spec(), SupportsChunks: true, SupportsArtifacts: true, }, validateOptions, func(request pipeline.BuildRequest) (contracts.SerializedValidator, error) { options, err := DecodeOptions(request.Options) @@ -62,14 +49,6 @@ func Register(registry *pipeline.ValidatorRegistry) error { return nil, err } return New(options), nil - }); err != nil { - return err - } - return registry.RegisterLegacyRawBuilderWithSpec(Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.LegacyRawValidator, error) { - if _, err := DecodeOptions(request.Options); err != nil { - return nil, err - } - return &legacyValidator{}, nil }) } diff --git a/internal/modules/generic/validate/valid_json/validator_test.go b/internal/modules/generic/validate/valid_json/validator_test.go index c8d4532..89eccff 100644 --- a/internal/modules/generic/validate/valid_json/validator_test.go +++ b/internal/modules/generic/validate/valid_json/validator_test.go @@ -47,12 +47,8 @@ func TestSpecAndRegister(t *testing.T) { if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } - validator, err := registry.BuildLegacyRaw(Key) - if err != nil { - t.Fatalf("Build(%q) error = %v, want nil", Key, err) - } - if validator.Name() != Key { - t.Fatalf("Name() = %q, want %q", validator.Name(), Key) + if registered, ok := registry.Spec(Key); !ok || registered.Key != Key { + t.Fatalf("Spec(%q) = %#v, %v", Key, registered, ok) } } diff --git a/internal/modules/generic/validate/valid_json_schema/validator.go b/internal/modules/generic/validate/valid_json_schema/validator.go index 0d8e228..30e5d13 100644 --- a/internal/modules/generic/validate/valid_json_schema/validator.go +++ b/internal/modules/generic/validate/valid_json_schema/validator.go @@ -17,10 +17,8 @@ const ReasonCodeSchemaInvalid = "json_schema_invalid" type Options struct{} type Validator struct{} -type legacyValidator struct{} var _ contracts.SerializedValidator = (*Validator)(nil) -var _ contracts.LegacyRawValidator = (*legacyValidator)(nil) func New(Options) *Validator { return &Validator{} } func (v *Validator) Name() string { return Key } @@ -32,14 +30,6 @@ func (v *Validator) Validate(_ context.Context, req contracts.SerializedValidati return validate(req.Content, req.Schema.JSONSchema) } -func (v *legacyValidator) Name() string { return Key } -func (v *legacyValidator) ExecutionClass() contracts.ExecutionClass { - return contracts.ExecutionClassDeterministic -} -func (v *legacyValidator) Validate(_ context.Context, req contracts.ValidationRequest) (contracts.ValidationResult, error) { - return validate(req.Payload.Content, req.Schema.JSONSchema) -} - func validate(content, schemaContent []byte) (contracts.ValidationResult, error) { if len(schemaContent) == 0 { return contracts.ValidationResult{}, fmt.Errorf("response schema content is not available") @@ -71,7 +61,7 @@ func Spec() pipeline.ValidatorSpec { } func Register(registry *pipeline.ValidatorRegistry) error { - if err := pipeline.RegisterSerializedValidatorBuilder(registry, pipeline.SerializedValidatorSpec{ + return pipeline.RegisterSerializedValidatorBuilder(registry, pipeline.SerializedValidatorSpec{ ValidatorSpec: Spec(), SupportsChunks: true, SupportsArtifacts: true, }, validateOptions, func(request pipeline.BuildRequest) (contracts.SerializedValidator, error) { options, err := DecodeOptions(request.Options) @@ -79,14 +69,6 @@ func Register(registry *pipeline.ValidatorRegistry) error { return nil, err } return New(options), nil - }); err != nil { - return err - } - return registry.RegisterLegacyRawBuilderWithSpec(Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.LegacyRawValidator, error) { - if _, err := DecodeOptions(request.Options); err != nil { - return nil, err - } - return &legacyValidator{}, nil }) } diff --git a/internal/modules/generic/validate/valid_json_schema/validator_test.go b/internal/modules/generic/validate/valid_json_schema/validator_test.go index 937b6f1..63f916d 100644 --- a/internal/modules/generic/validate/valid_json_schema/validator_test.go +++ b/internal/modules/generic/validate/valid_json_schema/validator_test.go @@ -74,12 +74,8 @@ func TestSpecAndRegister(t *testing.T) { if err := Register(registry); err != nil { t.Fatalf("Register() error = %v, want nil", err) } - validator, err := registry.BuildLegacyRaw(Key) - if err != nil { - t.Fatalf("Build(%q) error = %v, want nil", Key, err) - } - if validator.Name() != Key { - t.Fatalf("Name() = %q, want %q", validator.Name(), Key) + if registered, ok := registry.Spec(Key); !ok || registered.Key != Key { + t.Fatalf("Spec(%q) = %#v, %v", Key, registered, ok) } } diff --git a/internal/modules/integration/dnd_spells_config_test.go b/internal/modules/integration/dnd_spells_config_test.go index 7724687..9467d18 100644 --- a/internal/modules/integration/dnd_spells_config_test.go +++ b/internal/modules/integration/dnd_spells_config_test.go @@ -10,6 +10,7 @@ import ( "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd" spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder" @@ -178,37 +179,39 @@ func dndSpellsTestCatalog(t *testing.T, specs dndSpellsCatalogSpecs) pipeline.Mo t.Fatalf("register chunker: %v", err) } + codec := spellcodec.New() + if err := pipeline.RegisterArtifactCodec(codecs, codec); err != nil { + t.Fatalf("register dnd spells codec: %v", err) + } if specs.extractor.Key == "" { - codec := spellcodec.New() - if err := pipeline.RegisterArtifactCodec(codecs, codec); err != nil { - t.Fatalf("register dnd spells codec: %v", err) - } - if err := spells.RegisterWithRawAdapter(extractors, codec); err != nil { + if err := spells.Register(extractors); err != nil { t.Fatalf("register dnd spells extractor: %v", err) } } else { - specs.extractor.ArtifactKind = "" - if err := extractors.RegisterLegacyRawWithSpec(specs.extractor, func() (contracts.LegacyRawExtractor, error) { - return configLegacyExtractor{key: specs.extractor.Key}, nil + specs.extractor.ArtifactKind = dnd.SpellListKind + if err := pipeline.RegisterExtractor[dnd.SpellList](extractors, specs.extractor, func() (contracts.Extractor[dnd.SpellList], error) { + return configExtractor{key: specs.extractor.Key}, nil }); err != nil { t.Fatalf("register dnd spells extractor override: %v", err) } } - if err := mergers.RegisterLegacyRawWithSpec(pipeline.ModuleSpec{ - Key: pipeline.DefaultMergeModule, - Stage: pipeline.StageMerge, - Requires: []string{"dnd.spell_casts"}, - }, func() (contracts.LegacyRawMerger, error) { - return appendorder.New(), nil + if err := pipeline.RegisterMerger[dnd.SpellList](mergers, pipeline.ModuleSpec{ + Key: pipeline.DefaultMergeModule, + Stage: pipeline.StageMerge, + ArtifactKind: dnd.SpellListKind, + Requires: []string{"dnd.spell_casts"}, + }, func() (contracts.Merger[dnd.SpellList], error) { + return appendorder.NewTyped(appendSpellLists) }); err != nil { t.Fatalf("register merger: %v", err) } - if err := normalizers.RegisterLegacyRawWithSpec(pipeline.ModuleSpec{ - Key: pipeline.DefaultNormalizeModule, - Stage: pipeline.StageNormalize, - }, func() (contracts.LegacyRawNormalizer, error) { - return noop.New(), nil + if err := pipeline.RegisterNormalizer[dnd.SpellList](normalizers, pipeline.ModuleSpec{ + Key: pipeline.DefaultNormalizeModule, + Stage: pipeline.StageNormalize, + ArtifactKind: dnd.SpellListKind, + }, func() (contracts.Normalizer[dnd.SpellList], error) { + return noop.NewTyped[dnd.SpellList](), nil }); err != nil { t.Fatalf("register normalizer: %v", err) } @@ -233,12 +236,20 @@ func dndSpellsTestCatalog(t *testing.T, specs dndSpellsCatalogSpecs) pipeline.Mo } } -type configLegacyExtractor struct{ key string } +type configExtractor struct{ key string } -func (extractor configLegacyExtractor) Key() string { return extractor.key } -func (configLegacyExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } -func (configLegacyExtractor) Extract(context.Context, contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - return contracts.ExtractionResult{}, nil +func (extractor configExtractor) Key() string { return extractor.key } +func (configExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } +func (configExtractor) Extract(context.Context, contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[dnd.SpellList], error) { + return contracts.TypedExtractionResult[dnd.SpellList]{}, nil +} + +func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) { + combined := dnd.SpellList{SpellCasts: []dnd.SpellCast{}} + for _, value := range values { + combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...) + } + return combined, nil } func dndSpellsChunkerSpec() pipeline.ModuleSpec { diff --git a/internal/modules/integration/dnd_spells_runner_test.go b/internal/modules/integration/dnd_spells_runner_test.go index c2e1a5f..bafb89b 100644 --- a/internal/modules/integration/dnd_spells_runner_test.go +++ b/internal/modules/integration/dnd_spells_runner_test.go @@ -60,11 +60,11 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) { if len(output.NormalizeOutputs) != 1 { t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs)) } - rawOutput := output.NormalizeOutputs[0] - if rawOutput.LaneID != "spells" || rawOutput.Artifact.Schema.ID != spells.ResponseSchemaID || rawOutput.Artifact.Schema.Version != spells.SchemaVersion { - t.Fatalf("raw output envelope = %#v, want dnd spells schema on spells lane", rawOutput) + serializedOutput := output.NormalizeOutputs[0] + if serializedOutput.LaneID != "spells" || serializedOutput.Artifact.Schema.ID != spells.ResponseSchemaID || serializedOutput.Artifact.Schema.Version != spells.SchemaVersion { + t.Fatalf("serialized output envelope = %#v, want dnd spells schema on spells lane", serializedOutput) } - response := decodeRunnerSpellResponse(t, rawOutput.Artifact.Content) + response := decodeRunnerSpellResponse(t, serializedOutput.Artifact.Content) if len(response.SpellCasts) != 2 { t.Fatalf("len(spell_casts) = %d, want 2", len(response.SpellCasts)) } @@ -204,7 +204,7 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference(t *testing.T) { } } -func TestRunnerCarriesDNDSpellCastWithInvalidSourceRefAsRawOutput(t *testing.T) { +func TestRunnerCarriesDNDSpellCastWithInvalidSourceRefToSerializedOutput(t *testing.T) { raw := readDNDSpellsFixture(t) resolved := resolveDNDSpellsPipeline(t) llmClient := &fakeSpellsLLMClient{ @@ -235,7 +235,7 @@ func TestRunnerCarriesDNDSpellCastWithInvalidSourceRefAsRawOutput(t *testing.T) t.Fatalf("len(spell_casts) = %d, want 1", len(response.SpellCasts)) } if response.SpellCasts[0].SourceRefs[0].SourceID != "spell-session" { - t.Fatalf("SourceID = %q, want raw invalid source ref preserved", response.SpellCasts[0].SourceRefs[0].SourceID) + t.Fatalf("SourceID = %q, want invalid source ref preserved", response.SpellCasts[0].SourceRefs[0].SourceID) } if len(output.Rejected) != 0 { t.Fatalf("len(Rejected) = %d, want 0", len(output.Rejected)) @@ -282,7 +282,7 @@ func dndSpellsReferenceSet(party string, glossary string) contracts.ReferenceSet return contracts.ReferenceSet{Slots: slots} } -func TestRunnerCarriesMalformedDNDSpellsExtractorOutput(t *testing.T) { +func TestRunnerRejectsMalformedDNDSpellsArtifactAtSerializationBoundary(t *testing.T) { raw := readDNDSpellsFixture(t) resolved := resolveDNDSpellsPipeline(t) llmClient := &fakeSpellsLLMClient{response: extractionResponse{}} @@ -290,17 +290,11 @@ func TestRunnerCarriesMalformedDNDSpellsExtractorOutput(t *testing.T) { output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{ RawInput: raw, }) - if err != nil { - t.Fatalf("Run() error = %v, want nil", err) + if err == nil || !strings.Contains(err.Error(), "spell_casts must be present") { + t.Fatalf("Run() error = %v, want invalid spell-list serialization error", err) } - if len(output.NormalizeOutputs) != 1 { - t.Fatalf("len(NormalizeOutputs) = %d, want raw output", len(output.NormalizeOutputs)) - } - if string(output.NormalizeOutputs[0].Artifact.Content) != `{"spell_casts":null}` { - t.Fatalf("content = %s, want canonical structured output", output.NormalizeOutputs[0].Artifact.Content) - } - if output.Manifest.ValidationStatus != "approved" { - t.Fatalf("ValidationStatus = %q, want approved", output.Manifest.ValidationStatus) + if len(output.NormalizeOutputs) != 0 { + t.Fatalf("len(NormalizeOutputs) = %d, want no serialized malformed artifact", len(output.NormalizeOutputs)) } } diff --git a/internal/modules/seriatim/input/transcript/config_test.go b/internal/modules/seriatim/input/transcript/config_test.go index 56a7998..d26b6c2 100644 --- a/internal/modules/seriatim/input/transcript/config_test.go +++ b/internal/modules/seriatim/input/transcript/config_test.go @@ -2,12 +2,14 @@ package transcript import ( "context" + "encoding/json" "os" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder" @@ -131,29 +133,36 @@ func seriatimTestCatalog(t *testing.T, inputSpec pipeline.ModuleSpec) pipeline.M Provides: []string{"chunks"}, }) mustRegisterExtractor(t, extractors, pipeline.ModuleSpec{ - Key: "fake/extract", - Stage: pipeline.StageExtract, - Requires: []string{"chunks", "transcript.speaker", "transcript.timestamps"}, - Provides: []string{"fake.artifacts"}, + Key: "fake/extract", + Stage: pipeline.StageExtract, + ArtifactKind: seriatimArtifactKind, + Requires: []string{"chunks", "transcript.speaker", "transcript.timestamps"}, + Provides: []string{"fake.artifacts"}, }) mustRegisterMerger(t, mergers, pipeline.ModuleSpec{ - Key: pipeline.DefaultMergeModule, - Stage: pipeline.StageMerge, - Requires: []string{"fake.artifacts"}, + Key: pipeline.DefaultMergeModule, + Stage: pipeline.StageMerge, + ArtifactKind: seriatimArtifactKind, + Requires: []string{"fake.artifacts"}, }) mustRegisterNormalizer(t, normalizers, pipeline.ModuleSpec{ - Key: pipeline.DefaultNormalizeModule, - Stage: pipeline.StageNormalize, + Key: pipeline.DefaultNormalizeModule, + Stage: pipeline.StageNormalize, + ArtifactKind: seriatimArtifactKind, }) mustRegisterOutput(t, outputs, pipeline.ModuleSpec{ Key: pipeline.DefaultOutputModule, Stage: pipeline.StageOutput, }) + codecs := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(codecs, seriatimArtifactCodec{}); err != nil { + t.Fatalf("register artifact codec: %v", err) + } return pipeline.ModuleCatalog{ Inputs: inputs, Chunkers: chunkers, - ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), + ArtifactCodecs: codecs, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, @@ -173,7 +182,7 @@ func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawExtractor, error) { + if err := pipeline.RegisterExtractor[seriatimArtifact](registry, spec, func() (contracts.Extractor[seriatimArtifact], error) { return fakeExtractor{}, nil }); err != nil { t.Fatalf("register extractor: %v", err) @@ -182,8 +191,13 @@ func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, s func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawMerger, error) { - return appendorder.New(), nil + if err := pipeline.RegisterMerger[seriatimArtifact](registry, spec, func() (contracts.Merger[seriatimArtifact], error) { + return appendorder.NewTyped(func(values []seriatimArtifact) (seriatimArtifact, error) { + if len(values) == 0 { + return seriatimArtifact{}, nil + } + return values[0], nil + }) }); err != nil { t.Fatalf("register merger: %v", err) } @@ -191,8 +205,8 @@ func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pi func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { t.Helper() - if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawNormalizer, error) { - return noop.New(), nil + if err := pipeline.RegisterNormalizer[seriatimArtifact](registry, spec, func() (contracts.Normalizer[seriatimArtifact], error) { + return noop.NewTyped[seriatimArtifact](), nil }); err != nil { t.Fatalf("register normalizer: %v", err) } @@ -223,8 +237,8 @@ func (fakeExtractor) Key() string { return "fake/extract" } func (fakeExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } -func (fakeExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { - return contracts.ExtractionResult{}, nil +func (fakeExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[seriatimArtifact], error) { + return contracts.TypedExtractionResult[seriatimArtifact]{}, nil } type fakeOutput struct{} @@ -246,7 +260,30 @@ func withoutCapability(capabilities []string, capability string) []string { } var ( - _ contracts.Chunker = fakeChunker{} - _ contracts.LegacyRawExtractor = fakeExtractor{} - _ contracts.OutputEncoder = fakeOutput{} + _ contracts.Chunker = fakeChunker{} + _ contracts.Extractor[seriatimArtifact] = fakeExtractor{} + _ contracts.OutputEncoder = fakeOutput{} ) + +const seriatimArtifactKind contracts.ArtifactKind = "test/seriatim-event" + +type seriatimArtifact struct { + Value string `json:"value"` + SourceRefs []source.SourceRef `json:"source_refs"` +} + +type seriatimArtifactCodec struct{} + +func (seriatimArtifactCodec) Kind() contracts.ArtifactKind { return seriatimArtifactKind } +func (seriatimArtifactCodec) Schema() contracts.ArtifactSchema { + return contracts.ArtifactSchema{ID: "fake.event", Name: "fake_event", Version: "v1", JSONSchema: []byte(`{"type":"object"}`)} +} +func (seriatimArtifactCodec) MediaType() string { return "application/json" } +func (seriatimArtifactCodec) Encode(value seriatimArtifact) ([]byte, error) { + return json.Marshal(value) +} +func (seriatimArtifactCodec) Decode(content []byte) (seriatimArtifact, error) { + var value seriatimArtifact + err := json.Unmarshal(content, &value) + return value, err +} diff --git a/internal/modules/seriatim/input/transcript/runner_test.go b/internal/modules/seriatim/input/transcript/runner_test.go index c87b7c2..1bd58dd 100644 --- a/internal/modules/seriatim/input/transcript/runner_test.go +++ b/internal/modules/seriatim/input/transcript/runner_test.go @@ -55,16 +55,16 @@ func TestRunnerProcessesSeriatimInputWithFakeModules(t *testing.T) { t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs)) } - rawOutput := output.NormalizeOutputs[0] - if rawOutput.LaneID != "events" || rawOutput.NormalizerKey != pipeline.DefaultNormalizeModule || rawOutput.Artifact.Schema.ID != "fake.event" || rawOutput.Artifact.Schema.Version != "v1" { - t.Fatalf("raw output envelope = %#v, want fake extractor envelope", rawOutput) + serializedOutput := output.NormalizeOutputs[0] + if serializedOutput.LaneID != "events" || serializedOutput.NormalizerKey != pipeline.DefaultNormalizeModule || serializedOutput.Artifact.Schema.ID != "fake.event" || serializedOutput.Artifact.Schema.Version != "v1" { + t.Fatalf("serialized output envelope = %#v, want fake extractor envelope", serializedOutput) } var payload struct { Value string `json:"value"` SourceRefs []source.SourceRef `json:"source_refs"` } - if err := json.Unmarshal(rawOutput.Artifact.Content, &payload); err != nil { - t.Fatalf("Unmarshal(raw output) error = %v, want nil", err) + if err := json.Unmarshal(serializedOutput.Artifact.Content, &payload); err != nil { + t.Fatalf("Unmarshal(serialized output) error = %v, want nil", err) } if len(payload.SourceRefs) != 1 { t.Fatalf("len(SourceRefs) = %d, want 1", len(payload.SourceRefs)) @@ -115,7 +115,7 @@ func configResolveInput(t *testing.T) config.ResolveInput { } } -func seriatimRunnerRegistries(t *testing.T, extractor contracts.LegacyRawExtractor) pipeline.Registries { +func seriatimRunnerRegistries(t *testing.T, extractor contracts.Extractor[seriatimArtifact]) pipeline.Registries { t.Helper() inputs := pipeline.NewInputAdapterRegistry() @@ -133,19 +133,20 @@ func seriatimRunnerRegistries(t *testing.T, extractor contracts.LegacyRawExtract }); err != nil { t.Fatalf("register chunker: %v", err) } - if err := extractors.RegisterLegacyRaw("fake/extract", func() (contracts.LegacyRawExtractor, error) { + if err := pipeline.RegisterExtractor[seriatimArtifact](extractors, pipeline.ModuleSpec{Key: "fake/extract", Stage: pipeline.StageExtract, ArtifactKind: seriatimArtifactKind, Requires: []string{"chunks", "transcript.speaker", "transcript.timestamps"}, Provides: []string{"fake.artifacts"}}, func() (contracts.Extractor[seriatimArtifact], error) { return extractor, nil }); err != nil { t.Fatalf("register extractor: %v", err) } - if err := mergers.RegisterLegacyRaw(pipeline.DefaultMergeModule, func() (contracts.LegacyRawMerger, error) { - return appendorder.New(), nil + if err := appendorder.RegisterTyped(mergers, seriatimArtifactKind, func(values []seriatimArtifact) (seriatimArtifact, error) { + if len(values) == 0 { + return seriatimArtifact{}, nil + } + return values[0], nil }); err != nil { t.Fatalf("register merger: %v", err) } - if err := normalizers.RegisterLegacyRaw(pipeline.DefaultNormalizeModule, func() (contracts.LegacyRawNormalizer, error) { - return noop.New(), nil - }); err != nil { + if err := noop.RegisterTyped[seriatimArtifact](normalizers, seriatimArtifactKind); err != nil { t.Fatalf("register normalizer: %v", err) } if err := outputs.Register(pipeline.DefaultOutputModule, func() (contracts.OutputEncoder, error) { @@ -154,10 +155,14 @@ func seriatimRunnerRegistries(t *testing.T, extractor contracts.LegacyRawExtract t.Fatalf("register output: %v", err) } + codecs := pipeline.NewArtifactCodecRegistry() + if err := pipeline.RegisterArtifactCodec(codecs, seriatimArtifactCodec{}); err != nil { + t.Fatalf("register artifact codec: %v", err) + } return pipeline.Registries{ Inputs: inputs, Chunkers: chunkers, - ArtifactCodecs: pipeline.NewArtifactCodecRegistry(), + ArtifactCodecs: codecs, Extractors: extractors, Mergers: mergers, Normalizers: normalizers, @@ -203,36 +208,33 @@ func (e *runnerSeriatimExtractor) ReferenceSlots() []contracts.ReferenceSlot { return nil } -func (e *runnerSeriatimExtractor) Extract(ctx context.Context, req contracts.ExtractionRequest) (contracts.ExtractionResult, error) { +func (e *runnerSeriatimExtractor) Extract(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.TypedExtractionResult[seriatimArtifact], error) { e.calls++ if req.Source == nil { - return contracts.ExtractionResult{}, fmt.Errorf("source must not be nil") + return contracts.TypedExtractionResult[seriatimArtifact]{}, fmt.Errorf("source must not be nil") } if req.Chunk == nil { - return contracts.ExtractionResult{}, fmt.Errorf("chunk must not be nil") + return contracts.TypedExtractionResult[seriatimArtifact]{}, fmt.Errorf("chunk must not be nil") } if got := unitIDs(req.Source.Units); !equalInts(got, []int{1, 2}) { - return contracts.ExtractionResult{}, fmt.Errorf("source unit IDs = %#v, want Seriatim segment IDs", got) + return contracts.TypedExtractionResult[seriatimArtifact]{}, fmt.Errorf("source unit IDs = %#v, want Seriatim segment IDs", got) } if got := unitIDs(req.Chunk.Units); !equalInts(got, []int{1, 2}) { - return contracts.ExtractionResult{}, fmt.Errorf("chunk unit IDs = %#v, want Seriatim segment IDs", got) + return contracts.TypedExtractionResult[seriatimArtifact]{}, fmt.Errorf("chunk unit IDs = %#v, want Seriatim segment IDs", got) } for _, unit := range req.Chunk.Units { if speaker, ok := Speaker(unit); !ok || speaker == "" { - return contracts.ExtractionResult{}, fmt.Errorf("unit %d missing speaker metadata", unit.ID) + return contracts.TypedExtractionResult[seriatimArtifact]{}, fmt.Errorf("unit %d missing speaker metadata", unit.ID) } if _, ok := Start(unit); !ok { - return contracts.ExtractionResult{}, fmt.Errorf("unit %d missing start metadata", unit.ID) + return contracts.TypedExtractionResult[seriatimArtifact]{}, fmt.Errorf("unit %d missing start metadata", unit.ID) } if _, ok := End(unit); !ok { - return contracts.ExtractionResult{}, fmt.Errorf("unit %d missing end metadata", unit.ID) + return contracts.TypedExtractionResult[seriatimArtifact]{}, fmt.Errorf("unit %d missing end metadata", unit.ID) } } - payload, err := json.Marshal(struct { - Value string `json:"value"` - SourceRefs []source.SourceRef `json:"source_refs"` - }{ + return contracts.TypedExtractionResult[seriatimArtifact]{Value: seriatimArtifact{ Value: "seriatim-source-ref", SourceRefs: []source.SourceRef{ { @@ -241,20 +243,7 @@ func (e *runnerSeriatimExtractor) Extract(ctx context.Context, req contracts.Ext EndUnitID: req.Chunk.Units[len(req.Chunk.Units)-1].ID, }, }, - }) - if err != nil { - return contracts.ExtractionResult{}, err - } - - return contracts.ExtractionResult{ - Output: contracts.ExtractOutput{ - Schema: contracts.ResponseSchema{ID: "fake.event", Name: "fake_event", Version: "v1"}, - Payload: contracts.RawPayload{ - Content: payload, - MediaType: "application/json", - }, - }, - }, nil + }}, nil } type runnerSeriatimOutput struct{} @@ -292,7 +281,7 @@ func equalInts(a, b []int) bool { } var ( - _ contracts.Chunker = runnerSeriatimChunker{} - _ contracts.LegacyRawExtractor = (*runnerSeriatimExtractor)(nil) - _ contracts.OutputEncoder = runnerSeriatimOutput{} + _ contracts.Chunker = runnerSeriatimChunker{} + _ contracts.Extractor[seriatimArtifact] = (*runnerSeriatimExtractor)(nil) + _ contracts.OutputEncoder = runnerSeriatimOutput{} )