Prepare pipelines before source execution

This commit is contained in:
2026-07-17 06:18:46 +00:00
parent 1c84d19e5f
commit ce3a07512f
26 changed files with 1562 additions and 404 deletions

View File

@@ -33,7 +33,9 @@ without exposing Scriptorium types through stage contracts.
2. creating a `ScriptoriumClient` from the effective profile source;
3. attaching an `LLMProfileRecorder`;
4. creating a scheduler from the effective concurrency limit;
5. returning a `ScheduledClient` wrapper.
5. returning a `ScheduledClient` wrapper; and
6. injecting that one shared client into complete pipeline preparation before
the source file is read or the runner is invoked.
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

View File

@@ -19,10 +19,15 @@ validator chains and prompt asset collection.
Production extract, merge, normalize, and validator packages currently use the
explicit legacy raw registration APIs. Typed registration is framework-ready,
but no production artifact kind or codec is registered yet.
but no production artifact kind or codec is registered yet. All selected
production implementations are constructed during pipeline preparation through
temporary adapters around their existing zero-argument constructors. Their raw
option maps and LLM clients remain operation inputs until each implementation
migrates to its construction-owned decoder and injected dependencies.
Specs expose capability and execution metadata without constructing an
implementation. Chunk, extract, merge, and normalize modules that accept
implementation. Registry entries separately expose option validation and
run-local construction. Chunk, extract, merge, and normalize modules that accept
auxiliary material declare identical reference slots from both
`ReferenceSlots()` and `ModuleSpec().ReferenceSlots`; registration tests enforce
that agreement. Runtime delivery uses the corresponding stage request's

View File

@@ -15,7 +15,8 @@ output files returned by the runner. Diagnostics, checkpoints, and debug
recorders are optional side-channel collaborators supplied at this boundary.
Pipeline execution is serial. Resolution produces a fixed ordered workflow and
a sorted set of artifact lanes before the runner constructs any stage module.
a sorted set of artifact lanes. Preparation constructs the complete module and
validator set before the runner receives source bytes.
## Application Boundary
@@ -39,7 +40,7 @@ a sorted set of artifact lanes before the runner constructs any stage module.
| Package | Implemented responsibility |
| --- | --- |
| `internal/framework/contracts` | Source-stage contracts plus artifact identity, schema, serialized representation, codec, validator, reference, output, and structured-completion interfaces and data types. |
| `internal/framework/pipeline` | Module and artifact-codec registries, profile resolution, capability checks, reference materialization, validator-chain resolution, retries, orchestration, warnings, and manifest population. |
| `internal/framework/pipeline` | Module and artifact-codec registries, option validation, profile resolution, capability checks, reference materialization, complete pipeline preparation, retries, orchestration, warnings, and manifest population. |
| `internal/framework/validate` | Shared validator decision and cardinality helpers. |
| `internal/framework/llm` | Scriptorium-backed structured completions, prompt/schema registration, scheduling, profile recording, and secret redaction. |
| `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. |
@@ -56,9 +57,13 @@ Artifact registries support heterogeneous typed extraction entries and
kind-specific merger, normalizer, and validator variants. Resolution derives a
lane's kind from its extractor, requires the matching codec, verifies exact Go
type equality across the lane, and records schema identity in the resolved lane
and pipeline digest. Production module families do not register typed variants
yet and continue through explicitly named legacy raw registrations. The current
runner rejects a typed resolved lane instead of routing it through raw execution.
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 module families do not register typed variants yet and
continue through explicitly named legacy raw registrations and temporary
zero-argument constructor adapters. The current runner rejects a typed prepared
lane instead of routing it through raw execution.
## Production Extensions

View File

@@ -7,7 +7,8 @@ defaults, and selectable keys are defined in
[Configuration](../config.md#pipelines).
Pipeline execution is serial. Resolution fixes the selected lanes and all
stage bindings before the runner constructs stage implementations.
stage bindings; preparation constructs every selected implementation before the
runner begins source work.
## Resolution
@@ -24,7 +25,9 @@ calls `pipeline.ResolvePipeline`.
selects exact-type merger, normalizer, and validator variants;
5. checks required and provided capabilities in workflow order;
6. resolves target-aware reference bindings and validator chains;
7. calculates a digest over the resolved structure, including typed artifact
7. validates each selected module and validator option set through its registry
entry; and
8. calculates a digest over the resolved structure, including typed artifact
kind and schema identity.
Resolution returns a `ResolvedPipeline` containing ordered lanes, concrete
@@ -53,7 +56,8 @@ runtime sensitive-data handling belongs in [Operations](../operations.md).
## Registries And Specs
`pipeline.Registries` holds constructors used during execution.
`pipeline.Registries` holds option validators and run-local builders used during
resolution and preparation.
`pipeline.ModuleCatalog` exposes their specs during configuration validation and
resolution. Separate registries exist for every stage and for validators;
`ValidatorChainRegistry` stores production default-chain mappings. Both
@@ -77,24 +81,37 @@ 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`. Existing production registrations are adapted from their
zero-argument constructors while their implementation-owned option decoders are
migrated separately.
A `ValidatorSpec` declares a validator key and execution class. Resolution uses
the execution class to reject incompatible profile bindings before execution.
The current production catalog and default chain are listed only in
[Configuration](../config.md#implemented-production-validators).
## Runner Boundary
## Preparation And Runner Boundary
`pipeline.RunInput` carries the resolved pipeline, raw source input, structured
LLM client, run identity and timing, optional session and profile metadata, and
checkpoint/debug collaborators. The runner parses source bytes through the
selected input adapter. Later stage requests receive the generic source model;
extract requests receive chunk-scoped input material, while chunk, merge, and
normalize requests retain access to the original source material.
`pipeline.Prepare` receives a resolved pipeline, the registries, and shared
module dependencies. It constructs input; chunk and its validators; each lane's
extract, merge, and normalize modules and validator chains in resolved order;
then output. It stops at the first error with pipeline, stage, lane, module, and
validator context as applicable. It never invokes an operation method.
Typed lanes can be composed and resolved but are not passed to the current raw
runner. The runner rejects such input before source work; production lanes are
still resolved and executed exclusively through the legacy raw path.
`PreparedPipeline` keeps private constructed executors and exposes cloned
resolved input, chunk, lane, and output identities. `pipeline.RunInput` carries
that prepared pipeline, raw source input, run identity and timing, optional
session and profile metadata, and checkpoint/debug collaborators. The runner
parses source bytes through the already constructed input adapter. Later stage
requests receive the generic source model; extract requests receive
chunk-scoped input material, while chunk, merge, and normalize requests retain
access to the original source material.
Typed lanes can be composed, resolved, and prepared but are not executed by the
current raw runner. The runner rejects such input before source work;
production lanes are still resolved and executed exclusively through the
legacy raw path.
Source validation requires every unit to carry a canonical self-reference to
its containing document and its own unit ID. Explicit clone, checkpoint, and
@@ -111,17 +128,17 @@ runner returns.
The runner:
1. validates its input and registries;
2. builds the input adapter, parses the raw input, and validates the generic
1. validates its prepared input;
2. parses the raw input with the prepared adapter and validates the generic
source document;
3. obtains or executes the chunk result;
4. validates and canonicalizes chunks;
5. executes each resolved artifact lane in order;
6. builds the output encoder and validates its logical file results;
6. invokes the prepared output encoder and validates its logical file results;
7. returns the assembled manifest, outcomes, warnings, and files.
Within each artifact lane, it builds the extractor, merger, and normalizer,
then performs these transitions:
Within each artifact lane, it reuses the prepared extractor, merger, normalizer,
and validators while performing these transitions:
1. extract once per accepted chunk and add runner-owned lane, source, and chunk
provenance;
@@ -208,8 +225,10 @@ durable manifest and logical file schemas are defined in the
- `internal/framework/pipeline/artifact_codec_registry_test.go`: typed codec
metadata, registration, erasure safety, strict decoding, and cloning.
- `internal/framework/pipeline/typed_resolution_test.go`: heterogeneous typed
lane resolution, target-specific validators, incompatibilities, ordering, and
schema-sensitive pipeline identity.
lane resolution and preparation, target-specific validators,
incompatibilities, ordering, and schema-sensitive pipeline identity.
- `internal/framework/pipeline/preparation_test.go`: option validation,
construction order, dependency failures, and the before-source-work boundary.
- `internal/framework/pipeline/references_test.go`: target resolution and
materialization.
- `internal/framework/pipeline/runner_test.go`: stage transitions, retries,

View File

@@ -90,6 +90,12 @@ and verifies module availability and capabilities before execution. Structural
pipeline choices must not be scattered through conditionals or hidden behind
ad hoc command flags.
Resolution validates every selected module and validator option set. A separate
preparation boundary then constructs the complete input, chunk, lane,
validation, and output implementation set in pipeline order. The runner accepts
only that prepared set, so construction and dependency failures occur before
source parsing or any other module operation.
Stage ownership is explicit:
- input modules convert external material into the generic source model;

View File

@@ -263,11 +263,6 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("write diagnostics resolved references: %w", err))
}
rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath))
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err))
}
registries, err := effectiveRegistries(opts)
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
@@ -282,16 +277,23 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("create LLM client for profile %q: %w", factoryProfileID, err))
}
prepared, err := pipeline.Prepare(effective.ResolvedPipeline, registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("prepare pipeline %q: %w", pipelineID, err))
}
rawInput, err := os.ReadFile(strings.TrimSpace(*inputPath))
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, fmt.Errorf("read input %q: %w", strings.TrimSpace(*inputPath), err))
}
checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(workspaceSettings, effective.ResolvedPipeline, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), strings.TrimSpace(sessionID.value), *resume)
if err != nil {
return failPipelineCommand(stderr, runDir, cfg.Diagnostics.Retention, err)
}
output, err := pipeline.New(registries).Run(ctx, pipeline.RunInput{
Pipeline: effective.ResolvedPipeline,
output, err := pipeline.New().Run(ctx, pipeline.RunInput{
Prepared: prepared,
Path: strings.TrimSpace(*inputPath),
RawInput: rawInput,
LLMClient: llmClient,
SessionID: strings.TrimSpace(sessionID.value),
RunID: runID,
StartedAt: startedAt,

View File

@@ -3989,42 +3989,42 @@ func fakeCatalog(t *testing.T, overrides ...pipeline.ModuleSpec) pipeline.Module
func mustRegisterInput(t *testing.T, registry *pipeline.InputAdapterRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return nil, nil }); err != nil {
if err := registry.RegisterWithSpec(spec, func() (contracts.InputAdapter, error) { return fakeRunInputAdapter{}, nil }); err != nil {
t.Fatalf("register input: %v", err)
}
}
func mustRegisterChunker(t *testing.T, registry *pipeline.ChunkerRegistry, spec pipeline.ModuleSpec) {
t.Helper()
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return nil, nil }); err != nil {
if err := registry.RegisterWithSpec(spec, func() (contracts.Chunker, error) { return fakeRunChunker{}, nil }); err != nil {
t.Fatalf("register chunker: %v", err)
}
}
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 {
if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawExtractor, 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 nil, nil }); err != nil {
if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawMerger, 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 nil, nil }); err != nil {
if err := registry.RegisterLegacyRawWithSpec(spec, func() (contracts.LegacyRawNormalizer, error) { return fakeRunNormalizer{}, nil }); err != nil {
t.Fatalf("register normalizer: %v", err)
}
}
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 {
if err := registry.RegisterWithSpec(spec, func() (contracts.OutputEncoder, error) { return jsonoutput.New(), nil }); err != nil {
t.Fatalf("register output: %v", err)
}
}

View File

@@ -8,16 +8,19 @@ import (
)
type ChunkerConstructor func() (contracts.Chunker, error)
type ChunkerBuilder func(BuildRequest) (contracts.Chunker, error)
type ChunkerRegistry struct {
constructors map[string]ChunkerConstructor
specs map[string]ModuleSpec
builders map[string]ChunkerBuilder
optionValidators map[string]OptionValidator
specs map[string]ModuleSpec
}
func NewChunkerRegistry() *ChunkerRegistry {
return &ChunkerRegistry{
constructors: make(map[string]ChunkerConstructor),
specs: make(map[string]ModuleSpec),
builders: make(map[string]ChunkerBuilder),
optionValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec),
}
}
@@ -26,6 +29,15 @@ func (r *ChunkerRegistry) Register(key string, constructor ChunkerConstructor) e
}
func (r *ChunkerRegistry) RegisterWithSpec(spec ModuleSpec, constructor ChunkerConstructor) error {
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 constructor()
})
}
func (r *ChunkerRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder ChunkerBuilder) error {
if r == nil {
return fmt.Errorf("chunker registry must not be nil")
}
@@ -34,25 +46,36 @@ func (r *ChunkerRegistry) RegisterWithSpec(spec ModuleSpec, constructor ChunkerC
if err := validateModuleSpec("chunker", StageChunk, normalizedSpec); err != nil {
return err
}
if constructor == nil {
return fmt.Errorf("chunker constructor for %q must not be nil", normalizedSpec.Key)
if validateOptions == nil {
return fmt.Errorf("chunker option validator for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.constructors[normalizedSpec.Key]; ok {
if builder == nil {
return fmt.Errorf("chunker builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.builders[normalizedSpec.Key]; ok {
return fmt.Errorf("chunker %q is already registered", normalizedSpec.Key)
}
if r.constructors == nil {
r.constructors = make(map[string]ChunkerConstructor)
if r.builders == nil {
r.builders = make(map[string]ChunkerBuilder)
}
if r.optionValidators == nil {
r.optionValidators = make(map[string]OptionValidator)
}
if r.specs == nil {
r.specs = make(map[string]ModuleSpec)
}
r.constructors[normalizedSpec.Key] = constructor
r.builders[normalizedSpec.Key] = builder
r.optionValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil
}
func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) {
return r.BuildWithRequest(key, BuildRequest{})
}
func (r *ChunkerRegistry) BuildWithRequest(key string, request BuildRequest) (contracts.Chunker, error) {
if r == nil {
return nil, fmt.Errorf("chunker registry must not be nil")
}
@@ -62,12 +85,12 @@ func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) {
return nil, fmt.Errorf("chunker key must not be empty")
}
constructor, ok := r.constructors[normalizedKey]
builder, ok := r.builders[normalizedKey]
if !ok {
return nil, fmt.Errorf("chunker %q is not registered", normalizedKey)
}
chunker, err := constructor()
chunker, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, fmt.Errorf("build chunker %q: %w", normalizedKey, err)
}
@@ -81,6 +104,18 @@ func (r *ChunkerRegistry) Build(key string) (contracts.Chunker, error) {
return chunker, nil
}
func (r *ChunkerRegistry) ValidateOptions(key string, options map[string]any) error {
if r == nil {
return fmt.Errorf("chunker registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
validator, ok := r.optionValidators[normalizedKey]
if !ok {
return fmt.Errorf("chunker %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *ChunkerRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil {
return ModuleSpec{}, false
@@ -98,5 +133,5 @@ func (r *ChunkerRegistry) RegisteredKeys() []string {
return nil
}
return sortedRegistryKeys(r.constructors)
return sortedRegistryKeys(r.builders)
}

View File

@@ -0,0 +1,67 @@
package pipeline
import (
"fmt"
"sort"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// ModuleDependencies contains run-scoped collaborators shared by constructed
// modules. Implementations retain only the dependencies they use.
type ModuleDependencies struct {
LLM contracts.StructuredLLMClient
}
// BuildRequest contains the stable dependencies and configured options used to
// construct one module or validator for a run.
type BuildRequest struct {
Dependencies ModuleDependencies
Options map[string]any
}
// OptionValidator validates one module binding without constructing it.
type OptionValidator func(map[string]any) error
func allowLegacyOptions(map[string]any) error { return nil }
func validateRegisteredOptions(validator OptionValidator, options map[string]any) error {
if validator == nil {
return fmt.Errorf("option validator must not be nil")
}
return validator(cloneOptions(options))
}
func cloneBuildRequest(request BuildRequest) BuildRequest {
return BuildRequest{
Dependencies: request.Dependencies,
Options: cloneOptions(request.Options),
}
}
// RejectUnknownOptions provides the common strict-map check used by module-
// owned option decoders. Values remain the implementation's responsibility.
func RejectUnknownOptions(options map[string]any, allowed ...string) error {
known := make(map[string]struct{}, len(allowed))
for _, key := range allowed {
key = strings.TrimSpace(key)
if key != "" {
known[key] = struct{}{}
}
}
unknown := make([]string, 0)
for key := range options {
if _, ok := known[key]; !ok {
unknown = append(unknown, key)
}
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
if len(unknown) == 1 {
return fmt.Errorf("unknown option %q", unknown[0])
}
return fmt.Errorf("unknown options %q", unknown)
}

View File

@@ -9,24 +9,28 @@ import (
)
type LegacyRawExtractorConstructor func() (contracts.LegacyRawExtractor, error)
type LegacyRawExtractorBuilder func(BuildRequest) (contracts.LegacyRawExtractor, error)
type ExtractorRegistry struct {
legacyConstructors map[string]LegacyRawExtractorConstructor
typedEntries map[string]typedExtractorEntry
specs map[string]ModuleSpec
legacyBuilders map[string]LegacyRawExtractorBuilder
legacyValidators map[string]OptionValidator
typedEntries map[string]typedExtractorEntry
specs map[string]ModuleSpec
}
type typedExtractorEntry struct {
spec ModuleSpec
valueType reflect.Type
constructor func() (any, error)
spec ModuleSpec
valueType reflect.Type
validateOptions OptionValidator
builder func(BuildRequest) (any, error)
}
func NewExtractorRegistry() *ExtractorRegistry {
return &ExtractorRegistry{
legacyConstructors: make(map[string]LegacyRawExtractorConstructor),
typedEntries: make(map[string]typedExtractorEntry),
specs: make(map[string]ModuleSpec),
legacyBuilders: make(map[string]LegacyRawExtractorBuilder),
legacyValidators: make(map[string]OptionValidator),
typedEntries: make(map[string]typedExtractorEntry),
specs: make(map[string]ModuleSpec),
}
}
@@ -35,6 +39,15 @@ func (r *ExtractorRegistry) RegisterLegacyRaw(key string, constructor LegacyRawE
}
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")
}
@@ -45,24 +58,40 @@ func (r *ExtractorRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, construct
if normalizedSpec.ArtifactKind != "" {
return fmt.Errorf("legacy raw extractor %q must not declare an artifact kind", normalizedSpec.Key)
}
if constructor == nil {
return fmt.Errorf("extractor constructor for %q must not be nil", 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.legacyConstructors == nil {
r.legacyConstructors = make(map[string]LegacyRawExtractorConstructor)
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.legacyConstructors[normalizedSpec.Key] = constructor
r.legacyBuilders[normalizedSpec.Key] = builder
r.legacyValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil
}
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()
})
}
func RegisterExtractorBuilder[T any](registry *ExtractorRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Extractor[T], error)) error {
if registry == nil {
return fmt.Errorf("extractor registry must not be nil")
}
@@ -73,17 +102,21 @@ func RegisterExtractor[T any](registry *ExtractorRegistry, spec ModuleSpec, cons
if normalizedSpec.ArtifactKind == "" {
return fmt.Errorf("typed extractor %q artifact kind must not be empty", normalizedSpec.Key)
}
if constructor == nil {
return fmt.Errorf("extractor constructor for %q must not be nil", 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 := registry.specs[normalizedSpec.Key]; ok {
return fmt.Errorf("extractor %q is already registered", normalizedSpec.Key)
}
entry := typedExtractorEntry{
spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](),
constructor: func() (any, error) {
return constructor()
spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](),
validateOptions: validateOptions,
builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
},
}
if registry.typedEntries == nil {
@@ -98,6 +131,10 @@ func RegisterExtractor[T any](registry *ExtractorRegistry, spec ModuleSpec, cons
}
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")
}
@@ -105,11 +142,11 @@ func (r *ExtractorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawExtra
if normalizedKey == "" {
return nil, fmt.Errorf("extractor key must not be empty")
}
constructor, ok := r.legacyConstructors[normalizedKey]
builder, ok := r.legacyBuilders[normalizedKey]
if !ok {
return nil, fmt.Errorf("legacy raw extractor %q is not registered", normalizedKey)
}
extractor, err := constructor()
extractor, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, fmt.Errorf("build extractor %q: %w", normalizedKey, err)
}
@@ -122,6 +159,21 @@ func (r *ExtractorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawExtra
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]
if !ok {
return fmt.Errorf("extractor %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *ExtractorRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil {
return ModuleSpec{}, false

View File

@@ -8,16 +8,19 @@ import (
)
type InputAdapterConstructor func() (contracts.InputAdapter, error)
type InputAdapterBuilder func(BuildRequest) (contracts.InputAdapter, error)
type InputAdapterRegistry struct {
constructors map[string]InputAdapterConstructor
specs map[string]ModuleSpec
builders map[string]InputAdapterBuilder
optionValidators map[string]OptionValidator
specs map[string]ModuleSpec
}
func NewInputAdapterRegistry() *InputAdapterRegistry {
return &InputAdapterRegistry{
constructors: make(map[string]InputAdapterConstructor),
specs: make(map[string]ModuleSpec),
builders: make(map[string]InputAdapterBuilder),
optionValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec),
}
}
@@ -26,6 +29,15 @@ func (r *InputAdapterRegistry) Register(key string, constructor InputAdapterCons
}
func (r *InputAdapterRegistry) RegisterWithSpec(spec ModuleSpec, constructor InputAdapterConstructor) error {
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 constructor()
})
}
func (r *InputAdapterRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder InputAdapterBuilder) error {
if r == nil {
return fmt.Errorf("input adapter registry must not be nil")
}
@@ -34,25 +46,36 @@ func (r *InputAdapterRegistry) RegisterWithSpec(spec ModuleSpec, constructor Inp
if err := validateModuleSpec("input adapter", StageInput, normalizedSpec); err != nil {
return err
}
if constructor == nil {
return fmt.Errorf("input adapter constructor for %q must not be nil", normalizedSpec.Key)
if validateOptions == nil {
return fmt.Errorf("input adapter option validator for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.constructors[normalizedSpec.Key]; ok {
if builder == nil {
return fmt.Errorf("input adapter builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.builders[normalizedSpec.Key]; ok {
return fmt.Errorf("input adapter %q is already registered", normalizedSpec.Key)
}
if r.constructors == nil {
r.constructors = make(map[string]InputAdapterConstructor)
if r.builders == nil {
r.builders = make(map[string]InputAdapterBuilder)
}
if r.optionValidators == nil {
r.optionValidators = make(map[string]OptionValidator)
}
if r.specs == nil {
r.specs = make(map[string]ModuleSpec)
}
r.constructors[normalizedSpec.Key] = constructor
r.builders[normalizedSpec.Key] = builder
r.optionValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil
}
func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error) {
return r.BuildWithRequest(key, BuildRequest{})
}
func (r *InputAdapterRegistry) BuildWithRequest(key string, request BuildRequest) (contracts.InputAdapter, error) {
if r == nil {
return nil, fmt.Errorf("input adapter registry must not be nil")
}
@@ -62,12 +85,12 @@ func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error)
return nil, fmt.Errorf("input adapter key must not be empty")
}
constructor, ok := r.constructors[normalizedKey]
builder, ok := r.builders[normalizedKey]
if !ok {
return nil, fmt.Errorf("input adapter %q is not registered", normalizedKey)
}
adapter, err := constructor()
adapter, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, fmt.Errorf("build input adapter %q: %w", normalizedKey, err)
}
@@ -81,6 +104,18 @@ func (r *InputAdapterRegistry) Build(key string) (contracts.InputAdapter, error)
return adapter, nil
}
func (r *InputAdapterRegistry) ValidateOptions(key string, options map[string]any) error {
if r == nil {
return fmt.Errorf("input adapter registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
validator, ok := r.optionValidators[normalizedKey]
if !ok {
return fmt.Errorf("input adapter %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *InputAdapterRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil {
return ModuleSpec{}, false
@@ -98,5 +133,5 @@ func (r *InputAdapterRegistry) RegisteredKeys() []string {
return nil
}
return sortedRegistryKeys(r.constructors)
return sortedRegistryKeys(r.builders)
}

View File

@@ -9,6 +9,7 @@ import (
)
type LegacyRawMergerConstructor func() (contracts.LegacyRawMerger, error)
type LegacyRawMergerBuilder func(BuildRequest) (contracts.LegacyRawMerger, error)
type artifactVariantKey struct {
module string
@@ -16,22 +17,25 @@ type artifactVariantKey struct {
}
type MergerRegistry struct {
legacyConstructors map[string]LegacyRawMergerConstructor
legacySpecs map[string]ModuleSpec
typedEntries map[artifactVariantKey]typedMergerEntry
legacyBuilders map[string]LegacyRawMergerBuilder
legacyValidators map[string]OptionValidator
legacySpecs map[string]ModuleSpec
typedEntries map[artifactVariantKey]typedMergerEntry
}
type typedMergerEntry struct {
spec ModuleSpec
valueType reflect.Type
constructor func() (any, error)
spec ModuleSpec
valueType reflect.Type
validateOptions OptionValidator
builder func(BuildRequest) (any, error)
}
func NewMergerRegistry() *MergerRegistry {
return &MergerRegistry{
legacyConstructors: make(map[string]LegacyRawMergerConstructor),
legacySpecs: make(map[string]ModuleSpec),
typedEntries: make(map[artifactVariantKey]typedMergerEntry),
legacyBuilders: make(map[string]LegacyRawMergerBuilder),
legacyValidators: make(map[string]OptionValidator),
legacySpecs: make(map[string]ModuleSpec),
typedEntries: make(map[artifactVariantKey]typedMergerEntry),
}
}
@@ -40,6 +44,15 @@ func (r *MergerRegistry) RegisterLegacyRaw(key string, constructor LegacyRawMerg
}
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")
}
@@ -50,24 +63,40 @@ func (r *MergerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, constructor
if normalizedSpec.ArtifactKind != "" {
return fmt.Errorf("legacy raw merger %q must not declare an artifact kind", normalizedSpec.Key)
}
if constructor == nil {
return fmt.Errorf("merger constructor for %q must not be nil", normalizedSpec.Key)
if validateOptions == nil {
return fmt.Errorf("merger option validator for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.legacyConstructors[normalizedSpec.Key]; ok {
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.legacyConstructors == nil {
r.legacyConstructors = make(map[string]LegacyRawMergerConstructor)
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.legacyConstructors[normalizedSpec.Key] = constructor
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 constructor()
})
}
func RegisterMergerBuilder[T any](registry *MergerRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Merger[T], error)) error {
if registry == nil {
return fmt.Errorf("merger registry must not be nil")
}
@@ -78,8 +107,11 @@ func RegisterMerger[T any](registry *MergerRegistry, spec ModuleSpec, constructo
if normalizedSpec.ArtifactKind == "" {
return fmt.Errorf("typed merger %q artifact kind must not be empty", normalizedSpec.Key)
}
if constructor == nil {
return fmt.Errorf("merger constructor for %q must not be nil", 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)
}
key := artifactVariantKey{module: normalizedSpec.Key, kind: normalizedSpec.ArtifactKind}
if _, ok := registry.typedEntries[key]; ok {
@@ -89,16 +121,21 @@ func RegisterMerger[T any](registry *MergerRegistry, spec ModuleSpec, constructo
registry.typedEntries = make(map[artifactVariantKey]typedMergerEntry)
}
registry.typedEntries[key] = typedMergerEntry{
spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](),
constructor: func() (any, error) {
return constructor()
spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](),
validateOptions: validateOptions,
builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
},
}
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")
}
@@ -106,11 +143,11 @@ func (r *MergerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawMerger,
if normalizedKey == "" {
return nil, fmt.Errorf("merger key must not be empty")
}
constructor, ok := r.legacyConstructors[normalizedKey]
builder, ok := r.legacyBuilders[normalizedKey]
if !ok {
return nil, fmt.Errorf("legacy raw merger %q is not registered", normalizedKey)
}
merger, err := constructor()
merger, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, fmt.Errorf("build merger %q: %w", normalizedKey, err)
}
@@ -123,6 +160,25 @@ func (r *MergerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawMerger,
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]
if !ok {
return fmt.Errorf("legacy raw merger %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *MergerRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil {
return ModuleSpec{}, false

View File

@@ -9,24 +9,28 @@ import (
)
type LegacyRawNormalizerConstructor func() (contracts.LegacyRawNormalizer, error)
type LegacyRawNormalizerBuilder func(BuildRequest) (contracts.LegacyRawNormalizer, error)
type NormalizerRegistry struct {
legacyConstructors map[string]LegacyRawNormalizerConstructor
legacySpecs map[string]ModuleSpec
typedEntries map[artifactVariantKey]typedNormalizerEntry
legacyBuilders map[string]LegacyRawNormalizerBuilder
legacyValidators map[string]OptionValidator
legacySpecs map[string]ModuleSpec
typedEntries map[artifactVariantKey]typedNormalizerEntry
}
type typedNormalizerEntry struct {
spec ModuleSpec
valueType reflect.Type
constructor func() (any, error)
spec ModuleSpec
valueType reflect.Type
validateOptions OptionValidator
builder func(BuildRequest) (any, error)
}
func NewNormalizerRegistry() *NormalizerRegistry {
return &NormalizerRegistry{
legacyConstructors: make(map[string]LegacyRawNormalizerConstructor),
legacySpecs: make(map[string]ModuleSpec),
typedEntries: make(map[artifactVariantKey]typedNormalizerEntry),
legacyBuilders: make(map[string]LegacyRawNormalizerBuilder),
legacyValidators: make(map[string]OptionValidator),
legacySpecs: make(map[string]ModuleSpec),
typedEntries: make(map[artifactVariantKey]typedNormalizerEntry),
}
}
@@ -35,6 +39,15 @@ func (r *NormalizerRegistry) RegisterLegacyRaw(key string, constructor LegacyRaw
}
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")
}
@@ -45,24 +58,40 @@ func (r *NormalizerRegistry) RegisterLegacyRawWithSpec(spec ModuleSpec, construc
if normalizedSpec.ArtifactKind != "" {
return fmt.Errorf("legacy raw normalizer %q must not declare an artifact kind", normalizedSpec.Key)
}
if constructor == nil {
return fmt.Errorf("normalizer constructor for %q must not be nil", normalizedSpec.Key)
if validateOptions == nil {
return fmt.Errorf("normalizer option validator for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.legacyConstructors[normalizedSpec.Key]; ok {
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.legacyConstructors == nil {
r.legacyConstructors = make(map[string]LegacyRawNormalizerConstructor)
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.legacyConstructors[normalizedSpec.Key] = constructor
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 constructor()
})
}
func RegisterNormalizerBuilder[T any](registry *NormalizerRegistry, spec ModuleSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.Normalizer[T], error)) error {
if registry == nil {
return fmt.Errorf("normalizer registry must not be nil")
}
@@ -73,8 +102,11 @@ func RegisterNormalizer[T any](registry *NormalizerRegistry, spec ModuleSpec, co
if normalizedSpec.ArtifactKind == "" {
return fmt.Errorf("typed normalizer %q artifact kind must not be empty", normalizedSpec.Key)
}
if constructor == nil {
return fmt.Errorf("normalizer constructor for %q must not be nil", 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)
}
key := artifactVariantKey{module: normalizedSpec.Key, kind: normalizedSpec.ArtifactKind}
if _, ok := registry.typedEntries[key]; ok {
@@ -84,16 +116,21 @@ func RegisterNormalizer[T any](registry *NormalizerRegistry, spec ModuleSpec, co
registry.typedEntries = make(map[artifactVariantKey]typedNormalizerEntry)
}
registry.typedEntries[key] = typedNormalizerEntry{
spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](),
constructor: func() (any, error) {
return constructor()
spec: cloneModuleSpec(normalizedSpec),
valueType: reflect.TypeFor[T](),
validateOptions: validateOptions,
builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
},
}
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")
}
@@ -101,11 +138,11 @@ func (r *NormalizerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawNorm
if normalizedKey == "" {
return nil, fmt.Errorf("normalizer key must not be empty")
}
constructor, ok := r.legacyConstructors[normalizedKey]
builder, ok := r.legacyBuilders[normalizedKey]
if !ok {
return nil, fmt.Errorf("legacy raw normalizer %q is not registered", normalizedKey)
}
normalizer, err := constructor()
normalizer, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, fmt.Errorf("build normalizer %q: %w", normalizedKey, err)
}
@@ -118,6 +155,25 @@ func (r *NormalizerRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawNorm
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]
if !ok {
return fmt.Errorf("legacy raw normalizer %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *NormalizerRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil {
return ModuleSpec{}, false

View File

@@ -0,0 +1,65 @@
package pipeline
import "fmt"
func validateResolvedOptions(resolved ResolvedPipeline, catalog ModuleCatalog) error {
if err := catalog.Inputs.ValidateOptions(resolved.Input.Module, resolved.Input.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageInput, resolved.Input.Module, err)
}
if err := catalog.Chunkers.ValidateOptions(resolved.Chunk.Module, resolved.Chunk.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageChunk, resolved.Chunk.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageChunk, "", resolved.Chunk.Module); err != nil {
return err
}
for _, lane := range resolved.ArtifactLanes {
if err := catalog.Extractors.validateOptions(lane.Extract.Module, lane.Extract.Options); err != nil {
return moduleOptionsError(resolved.ID, lane.ID, StageExtract, lane.Extract.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageExtract, lane.ID, lane.Extract.Module); err != nil {
return err
}
if err := catalog.Mergers.validateOptions(lane.Merge.Module, lane.ArtifactKind, lane.Merge.Options); err != nil {
return moduleOptionsError(resolved.ID, lane.ID, StageMerge, lane.Merge.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageMerge, lane.ID, lane.Merge.Module); err != nil {
return err
}
if err := catalog.Normalizers.validateOptions(lane.Normalize.Module, lane.ArtifactKind, lane.Normalize.Options); err != nil {
return moduleOptionsError(resolved.ID, lane.ID, StageNormalize, lane.Normalize.Module, err)
}
if err := validateChainOptions(resolved, catalog, StageNormalize, lane.ID, lane.Normalize.Module); err != nil {
return err
}
}
if err := catalog.Outputs.ValidateOptions(resolved.Output.Module, resolved.Output.Options); err != nil {
return moduleOptionsError(resolved.ID, "", StageOutput, resolved.Output.Module, err)
}
return nil
}
func validateChainOptions(resolved ResolvedPipeline, catalog ModuleCatalog, stage ModuleStage, laneID, moduleKey string) error {
chain := resolvedValidatorChain(stage, laneID, moduleKey, resolved.ValidatorChains)
for _, validator := range chain.Validators {
if err := catalog.Validators.validateOptions(validator); err != nil {
return validatorOptionsError(resolved.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
}
}
return nil
}
func moduleOptionsError(pipelineID, laneID string, stage ModuleStage, moduleKey string, cause error) error {
if laneID == "" {
return fmt.Errorf("pipeline %q %s module %q options: %w", pipelineID, stage, moduleKey, cause)
}
return fmt.Errorf("pipeline %q lane %q %s module %q options: %w", pipelineID, laneID, stage, moduleKey, cause)
}
func validatorOptionsError(pipelineID, laneID string, stage ModuleStage, moduleKey, validatorKey string, cause error) error {
if laneID == "" {
return fmt.Errorf("pipeline %q %s module %q validator %q options: %w", pipelineID, stage, moduleKey, validatorKey, cause)
}
return fmt.Errorf("pipeline %q lane %q %s module %q validator %q options: %w", pipelineID, laneID, stage, moduleKey, validatorKey, cause)
}

View File

@@ -8,16 +8,19 @@ import (
)
type OutputEncoderConstructor func() (contracts.OutputEncoder, error)
type OutputEncoderBuilder func(BuildRequest) (contracts.OutputEncoder, error)
type OutputEncoderRegistry struct {
constructors map[string]OutputEncoderConstructor
specs map[string]ModuleSpec
builders map[string]OutputEncoderBuilder
optionValidators map[string]OptionValidator
specs map[string]ModuleSpec
}
func NewOutputEncoderRegistry() *OutputEncoderRegistry {
return &OutputEncoderRegistry{
constructors: make(map[string]OutputEncoderConstructor),
specs: make(map[string]ModuleSpec),
builders: make(map[string]OutputEncoderBuilder),
optionValidators: make(map[string]OptionValidator),
specs: make(map[string]ModuleSpec),
}
}
@@ -26,6 +29,15 @@ func (r *OutputEncoderRegistry) Register(key string, constructor OutputEncoderCo
}
func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor OutputEncoderConstructor) error {
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 constructor()
})
}
func (r *OutputEncoderRegistry) RegisterBuilderWithSpec(spec ModuleSpec, validateOptions OptionValidator, builder OutputEncoderBuilder) error {
if r == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
@@ -34,25 +46,36 @@ func (r *OutputEncoderRegistry) RegisterWithSpec(spec ModuleSpec, constructor Ou
if err := validateModuleSpec("output encoder", StageOutput, normalizedSpec); err != nil {
return err
}
if constructor == nil {
return fmt.Errorf("output encoder constructor for %q must not be nil", normalizedSpec.Key)
if validateOptions == nil {
return fmt.Errorf("output encoder option validator for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.constructors[normalizedSpec.Key]; ok {
if builder == nil {
return fmt.Errorf("output encoder builder for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.builders[normalizedSpec.Key]; ok {
return fmt.Errorf("output encoder %q is already registered", normalizedSpec.Key)
}
if r.constructors == nil {
r.constructors = make(map[string]OutputEncoderConstructor)
if r.builders == nil {
r.builders = make(map[string]OutputEncoderBuilder)
}
if r.optionValidators == nil {
r.optionValidators = make(map[string]OptionValidator)
}
if r.specs == nil {
r.specs = make(map[string]ModuleSpec)
}
r.constructors[normalizedSpec.Key] = constructor
r.builders[normalizedSpec.Key] = builder
r.optionValidators[normalizedSpec.Key] = validateOptions
r.specs[normalizedSpec.Key] = cloneModuleSpec(normalizedSpec)
return nil
}
func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, error) {
return r.BuildWithRequest(key, BuildRequest{})
}
func (r *OutputEncoderRegistry) BuildWithRequest(key string, request BuildRequest) (contracts.OutputEncoder, error) {
if r == nil {
return nil, fmt.Errorf("output encoder registry must not be nil")
}
@@ -62,12 +85,12 @@ func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, erro
return nil, fmt.Errorf("output encoder key must not be empty")
}
constructor, ok := r.constructors[normalizedKey]
builder, ok := r.builders[normalizedKey]
if !ok {
return nil, fmt.Errorf("output encoder %q is not registered", normalizedKey)
}
encoder, err := constructor()
encoder, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, fmt.Errorf("build output encoder %q: %w", normalizedKey, err)
}
@@ -81,6 +104,18 @@ func (r *OutputEncoderRegistry) Build(key string) (contracts.OutputEncoder, erro
return encoder, nil
}
func (r *OutputEncoderRegistry) ValidateOptions(key string, options map[string]any) error {
if r == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
normalizedKey := strings.TrimSpace(key)
validator, ok := r.optionValidators[normalizedKey]
if !ok {
return fmt.Errorf("output encoder %q is not registered", normalizedKey)
}
return validateRegisteredOptions(validator, options)
}
func (r *OutputEncoderRegistry) Spec(key string) (ModuleSpec, bool) {
if r == nil {
return ModuleSpec{}, false
@@ -98,5 +133,5 @@ func (r *OutputEncoderRegistry) RegisteredKeys() []string {
return nil
}
return sortedRegistryKeys(r.constructors)
return sortedRegistryKeys(r.builders)
}

View File

@@ -0,0 +1,211 @@
package pipeline
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
func TestResolvePipelineValidatesModuleAndValidatorOptions(t *testing.T) {
tests := []struct {
name string
mutate func(*PipelineProfile)
want []string
}{
{
name: "module",
mutate: func(profile *PipelineProfile) {
profile.Input.Options = map[string]any{"surprise": true}
},
want: []string{`pipeline "construction" input module "input" options`, `unknown option "surprise"`},
},
{
name: "validator",
mutate: func(profile *PipelineProfile) {
profile.Chunk.Validators = ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured", Options: map[string]any{"surprise": true}}}}
},
want: []string{`pipeline "construction" chunk module "chunk" validator "configured" options`, `unknown option "surprise"`},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
registries, _ := constructionRegistries(t, nil, nil)
profile := constructionProfile()
test.mutate(&profile)
_, err := ResolvePipeline(profile, ResolveOptions{}, registries.catalog())
if err == nil {
t.Fatal("ResolvePipeline() error = nil, want option validation error")
}
for _, want := range test.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("ResolvePipeline() error = %q, want substring %q", err, want)
}
}
})
}
}
func TestPrepareConstructsEverythingInStableOrder(t *testing.T) {
var built []string
registries, _ := constructionRegistries(t, &built, nil)
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
if err != nil {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
prepared, err := Prepare(resolved, registries, ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
want := []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"}
if !reflect.DeepEqual(built, want) {
t.Fatalf("construction order = %#v, want %#v", built, want)
}
if prepared.Input.Module != "input" || prepared.Chunk.Module != "chunk" || prepared.Output.Module != "output" || len(prepared.ArtifactLanes) != 1 {
t.Fatalf("PreparedPipeline = %#v, want explicit resolved components", prepared)
}
}
func TestPrepareFailuresOccurBeforeInputParse(t *testing.T) {
tests := []struct {
name string
deps ModuleDependencies
configure func(*constructionFailure)
want string
wantBuilt []string
}{
{
name: "missing required llm dependency",
configure: func(failure *constructionFailure) {
failure.requireExtractorLLM = true
},
want: `lane "artifact" extract module "extract"`,
wantBuilt: []string{"input", "chunk", "validator", "extract"},
},
{
name: "late output construction",
configure: func(failure *constructionFailure) {
failure.output = errors.New("output unavailable")
},
want: `output module "output"`,
wantBuilt: []string{"input", "chunk", "validator", "extract", "validator", "merge", "validator", "normalize", "validator", "output"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
failure := &constructionFailure{}
test.configure(failure)
var built []string
registries, input := constructionRegistries(t, &built, failure)
resolved, err := ResolvePipeline(constructionProfile(), ResolveOptions{}, registries.catalog())
if err != nil {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
_, err = Prepare(resolved, registries, test.deps)
if err == nil || !strings.Contains(err.Error(), test.want) {
t.Fatalf("Prepare() error = %v, want substring %q", err, test.want)
}
if len(input.requests) != 0 {
t.Fatalf("input Parse calls = %d, want zero", len(input.requests))
}
if !reflect.DeepEqual(built, test.wantBuilt) {
t.Fatalf("construction order = %#v, want %#v", built, test.wantBuilt)
}
})
}
}
type constructionFailure struct {
requireExtractorLLM bool
output error
}
func constructionProfile() PipelineProfile {
validators := ValidatorOverride{Set: true, Validators: []ModuleBinding{{Module: "configured"}}}
return PipelineProfile{
ID: "construction",
Input: Binding("input"),
Chunk: ModuleBinding{Module: "chunk", Validators: validators},
Artifacts: map[string]ArtifactLaneProfile{
"artifact": {
Extract: ModuleBinding{Module: "extract", Validators: validators},
Merge: ModuleBinding{Module: "merge", Validators: validators},
Normalize: ModuleBinding{Module: "normalize", Validators: validators},
},
},
Output: Binding("output"),
}
}
func constructionRegistries(t *testing.T, built *[]string, failure *constructionFailure) (Registries, *runnerInputAdapter) {
t.Helper()
if built == nil {
built = &[]string{}
}
if failure == nil {
failure = &constructionFailure{}
}
record := func(name string) { *built = append(*built, name) }
strict := func(options map[string]any) error { return RejectUnknownOptions(options, "known") }
modules := defaultRunnerModules()
registries := Registries{
Inputs: NewInputAdapterRegistry(), Chunkers: NewChunkerRegistry(), ArtifactCodecs: NewArtifactCodecRegistry(),
Extractors: NewExtractorRegistry(), Mergers: NewMergerRegistry(), Normalizers: NewNormalizerRegistry(),
Validators: NewValidatorRegistry(), ValidatorChains: NewValidatorChainRegistry(), Outputs: NewOutputEncoderRegistry(),
}
if err := registries.Inputs.RegisterBuilderWithSpec(defaultModuleSpec("input", StageInput), strict, func(BuildRequest) (contracts.InputAdapter, error) {
record("input")
return modules.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
}); err != nil {
t.Fatal(err)
}
if err := registries.Extractors.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("extract", StageExtract), strict, func(request BuildRequest) (contracts.LegacyRawExtractor, error) {
record("extract")
if failure.requireExtractorLLM && request.Dependencies.LLM == nil {
return nil, errors.New("structured LLM client is required")
}
return &runnerExtractor{key: "extract"}, nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Mergers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("merge", StageMerge), strict, func(BuildRequest) (contracts.LegacyRawMerger, error) {
record("merge")
return modules.mergers["merge"], nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Normalizers.RegisterLegacyRawBuilderWithSpec(defaultModuleSpec("normalize", StageNormalize), strict, func(BuildRequest) (contracts.LegacyRawNormalizer, error) {
record("normalize")
return modules.normalizers["normalize"], nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Validators.RegisterLegacyRawBuilderWithSpec(ValidatorSpec{Key: "configured", ExecutionClass: contracts.ExecutionClassDeterministic}, strict, func(BuildRequest) (contracts.LegacyRawValidator, error) {
record("validator")
return modules.validators["configured"], nil
}); err != nil {
t.Fatal(err)
}
if err := registries.Outputs.RegisterBuilderWithSpec(defaultModuleSpec("output", StageOutput), strict, func(BuildRequest) (contracts.OutputEncoder, error) {
record("output")
if failure.output != nil {
return nil, failure.output
}
return modules.output, nil
}); err != nil {
t.Fatal(err)
}
return registries, modules.input
}

View File

@@ -0,0 +1,370 @@
package pipeline
import (
"fmt"
"reflect"
"strings"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
)
// PreparedPipeline owns the constructed, run-local implementation set for one
// resolved pipeline. Its implementation values are private so execution cannot
// replace or reconfigure them after preparation.
type PreparedPipeline struct {
Input ModuleBinding
Chunk ModuleBinding
ArtifactLanes []PreparedArtifactLane
Output ModuleBinding
resolved ResolvedPipeline
dependencies ModuleDependencies
input contracts.InputAdapter
chunker contracts.Chunker
chunkValidators preparedValidatorChain
lanes []preparedLaneExecutor
output contracts.OutputEncoder
}
type PreparedArtifactLane struct {
Resolved ResolvedArtifactLane
}
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
normalizer any
}
type preparedValidatorChain struct {
resolved ResolvedValidatorChain
validators []preparedValidator
}
type preparedValidator struct {
resolved ResolvedValidator
legacy contracts.LegacyRawValidator
typed any
chunk contracts.ChunkValidator
serialized contracts.SerializedValidator
}
// Prepare validates all configured options and constructs every selected
// module and validator before any operation method can run.
func Prepare(resolved ResolvedPipeline, registries Registries, deps ModuleDependencies) (*PreparedPipeline, error) {
if err := validateResolvedPipeline(resolved, false); err != nil {
return nil, err
}
if err := validateRegistrySet(resolved, registries); err != nil {
return nil, err
}
stable := cloneResolvedPipeline(resolved)
prepared := &PreparedPipeline{
Input: cloneModuleBinding(stable.Input),
Chunk: cloneModuleBinding(stable.Chunk),
Output: cloneModuleBinding(stable.Output),
resolved: stable,
dependencies: deps,
}
request := func(binding ModuleBinding) BuildRequest {
return BuildRequest{Dependencies: deps, Options: cloneOptions(binding.Options)}
}
input, err := registries.Inputs.BuildWithRequest(stable.Input.Module, request(stable.Input))
if err != nil {
return nil, constructionError(stable.ID, "", StageInput, stable.Input.Module, "", err)
}
prepared.input = input
chunker, err := registries.Chunkers.BuildWithRequest(stable.Chunk.Module, request(stable.Chunk))
if err != nil {
return nil, constructionError(stable.ID, "", StageChunk, stable.Chunk.Module, "", err)
}
prepared.chunker = chunker
prepared.chunkValidators, err = prepareValidatorChain(stable, registries, deps, StageChunk, "", stable.Chunk.Module)
if err != nil {
return nil, err
}
prepared.ArtifactLanes = make([]PreparedArtifactLane, 0, len(stable.ArtifactLanes))
prepared.lanes = make([]preparedLaneExecutor, 0, len(stable.ArtifactLanes))
for _, lane := range stable.ArtifactLanes {
executor, err := prepareLane(stable, lane, registries, deps)
if err != nil {
return nil, err
}
prepared.ArtifactLanes = append(prepared.ArtifactLanes, PreparedArtifactLane{Resolved: cloneResolvedArtifactLane(lane)})
prepared.lanes = append(prepared.lanes, executor)
}
output, err := registries.Outputs.BuildWithRequest(stable.Output.Module, request(stable.Output))
if err != nil {
return nil, constructionError(stable.ID, "", StageOutput, stable.Output.Module, "", err)
}
prepared.output = output
return prepared, nil
}
func prepareLane(pipeline ResolvedPipeline, lane ResolvedArtifactLane, registries Registries, deps ModuleDependencies) (preparedLaneExecutor, error) {
executor := preparedLaneExecutor{resolved: cloneResolvedArtifactLane(lane)}
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)
}
executor.typed = &preparedTypedLane{extractor: module}
}
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.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.normalizeValidators, err = prepareValidatorChain(pipeline, registries, deps, StageNormalize, lane.ID, lane.Normalize.Module)
if err != nil {
return preparedLaneExecutor{}, err
}
return executor, nil
}
func prepareValidatorChain(pipeline ResolvedPipeline, registries Registries, deps ModuleDependencies, stage ModuleStage, laneID, moduleKey string) (preparedValidatorChain, error) {
resolved := resolvedValidatorChain(stage, laneID, moduleKey, pipeline.ValidatorChains)
prepared := preparedValidatorChain{resolved: resolved}
for _, validator := range resolved.Validators {
request := BuildRequest{Dependencies: deps, Options: cloneOptions(validator.Binding.Options)}
built, err := buildPreparedValidator(registries.Validators, validator, request)
if err != nil {
return preparedValidatorChain{}, constructionError(pipeline.ID, laneID, stage, moduleKey, validator.Binding.Module, err)
}
prepared.validators = append(prepared.validators, built)
}
return prepared, nil
}
func buildPreparedValidator(registry *ValidatorRegistry, resolved ResolvedValidator, request BuildRequest) (preparedValidator, error) {
prepared := preparedValidator{resolved: resolved}
key := resolved.Binding.Module
var implementation any
var err error
switch resolved.Target {
case ValidatorTargetTyped:
entry, ok := registry.typedEntry(key, resolved.ArtifactKind)
if !ok {
return preparedValidator{}, fmt.Errorf("typed construction entry is not registered")
}
implementation, err = entry.builder(cloneBuildRequest(request))
prepared.typed = implementation
case ValidatorTargetChunk:
entry, ok := registry.chunkEntry(key)
if !ok {
return preparedValidator{}, fmt.Errorf("chunk construction entry is not registered")
}
prepared.chunk, err = entry.builder(cloneBuildRequest(request))
implementation = prepared.chunk
case ValidatorTargetSerialized:
entry, ok := registry.serializedEntry(key)
if !ok {
return preparedValidator{}, fmt.Errorf("serialized construction entry is not registered")
}
prepared.serialized, err = entry.builder(cloneBuildRequest(request))
implementation = prepared.serialized
default:
prepared.legacy, err = registry.BuildLegacyRawWithRequest(key, request)
implementation = prepared.legacy
}
if err != nil {
return preparedValidator{}, err
}
if isNilImplementation(implementation) {
return preparedValidator{}, fmt.Errorf("validator %q builder returned nil", key)
}
identity, ok := implementation.(interface {
Name() string
ExecutionClass() contracts.ExecutionClass
})
if !ok {
return preparedValidator{}, fmt.Errorf("validator %q builder returned incompatible implementation %T", key, implementation)
}
if identity.Name() != key {
return preparedValidator{}, fmt.Errorf("validator %q returned name %q", key, identity.Name())
}
if identity.ExecutionClass() != resolved.ExecutionClass {
return preparedValidator{}, fmt.Errorf("validator %q returned execution class %q, want %q", key, identity.ExecutionClass(), resolved.ExecutionClass)
}
return prepared, nil
}
func buildErasedModule(builder func(BuildRequest) (any, error), request BuildRequest, key, kind string) (any, error) {
implementation, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, err
}
if isNilImplementation(implementation) {
return nil, fmt.Errorf("%s %q builder returned nil", kind, key)
}
identity, ok := implementation.(interface{ Key() string })
if !ok {
return nil, fmt.Errorf("%s %q builder returned incompatible implementation %T", kind, key, implementation)
}
if identity.Key() != key {
return nil, fmt.Errorf("%s %q returned key %q", kind, key, identity.Key())
}
return implementation, nil
}
func isNilImplementation(value any) bool {
if value == nil {
return true
}
reflected := reflect.ValueOf(value)
switch reflected.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return reflected.IsNil()
default:
return false
}
}
func constructionError(pipelineID, laneID string, stage ModuleStage, moduleKey, validatorKey string, cause error) error {
scope := fmt.Sprintf("pipeline %q %s module %q", pipelineID, stage, moduleKey)
if laneID != "" {
scope = fmt.Sprintf("pipeline %q lane %q %s module %q", pipelineID, laneID, stage, moduleKey)
}
if strings.TrimSpace(validatorKey) != "" {
scope += fmt.Sprintf(" validator %q", validatorKey)
}
return fmt.Errorf("prepare %s: %w", scope, cause)
}
func (registries Registries) catalog() ModuleCatalog {
return ModuleCatalog{
Inputs: registries.Inputs, Chunkers: registries.Chunkers, ArtifactCodecs: registries.ArtifactCodecs,
Extractors: registries.Extractors, Mergers: registries.Mergers, Normalizers: registries.Normalizers,
Validators: registries.Validators, ValidatorChains: registries.ValidatorChains, Outputs: registries.Outputs,
}
}
func validateRegistrySet(resolved ResolvedPipeline, registries Registries) error {
if registries.Inputs == nil {
return fmt.Errorf("input registry must not be nil")
}
if registries.Chunkers == nil {
return fmt.Errorf("chunker registry must not be nil")
}
if registries.Extractors == nil {
return fmt.Errorf("extractor registry must not be nil")
}
if registries.Mergers == nil {
return fmt.Errorf("merger registry must not be nil")
}
if registries.Normalizers == nil {
return fmt.Errorf("normalizer registry must not be nil")
}
if registries.Validators == nil {
for _, chain := range resolved.ValidatorChains {
if len(chain.Validators) > 0 {
return fmt.Errorf("validator registry must not be nil")
}
}
}
if registries.Outputs == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
return nil
}
func cloneResolvedPipeline(in ResolvedPipeline) ResolvedPipeline {
out := in
out.Input = cloneModuleBinding(in.Input)
out.Chunk = cloneModuleBinding(in.Chunk)
out.Output = cloneModuleBinding(in.Output)
out.ChunkReferences = CloneReferenceTarget(in.ChunkReferences)
out.ValidatorChains = cloneResolvedValidatorChains(in.ValidatorChains)
if len(in.ArtifactLanes) > 0 {
out.ArtifactLanes = make([]ResolvedArtifactLane, len(in.ArtifactLanes))
for i, lane := range in.ArtifactLanes {
out.ArtifactLanes[i] = cloneResolvedArtifactLane(lane)
}
}
return out
}
func cloneResolvedArtifactLane(in ResolvedArtifactLane) ResolvedArtifactLane {
out := in
out.Extract = cloneModuleBinding(in.Extract)
out.Merge = cloneModuleBinding(in.Merge)
out.Normalize = cloneModuleBinding(in.Normalize)
out.Validators = cloneModuleBindings(in.Validators)
out.ExtractReferences = CloneReferenceTarget(in.ExtractReferences)
out.MergeReferences = CloneReferenceTarget(in.MergeReferences)
out.NormalizeReferences = CloneReferenceTarget(in.NormalizeReferences)
return out
}

View File

@@ -249,6 +249,9 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo
if missing, ok := outputCapabilities.missing(outputSpec.Requires); ok {
return ResolvedPipeline{}, capabilityError(pipelineID, "", StageOutput, resolved.Output.Module, missing)
}
if err := validateResolvedOptions(resolved, catalog); err != nil {
return ResolvedPipeline{}, err
}
digest, err := resolvedPipelineDigest(resolved)
if err != nil {

View File

@@ -14,8 +14,8 @@ func TestRunnerUsesRegistries(t *testing.T) {
var executed []string
registries := integrationRegistries(t, &built, &executed)
output, err := New(registries).Run(context.Background(), RunInput{
Pipeline: integrationPipeline(),
output, err := newPreparedRunner(t, registries).Run(context.Background(), RunInput{
pipeline: integrationPipeline(),
SourceID: "source-1",
RawInput: []byte("source text"),
})

View File

@@ -29,20 +29,17 @@ type Registries struct {
Outputs *OutputEncoderRegistry
}
type Runner struct {
registries Registries
}
type Runner struct{}
func New(registries Registries) *Runner {
return &Runner{registries: registries}
func New() *Runner {
return &Runner{}
}
type RunInput struct {
Pipeline ResolvedPipeline
Prepared *PreparedPipeline
SourceID string
Path string
RawInput []byte
LLMClient contracts.StructuredLLMClient
SessionID string
RunID string
StartedAt time.Time
@@ -52,6 +49,9 @@ type RunInput struct {
Checkpoints CheckpointRecorder
Checkpoint CheckpointLoader
Debug DebugRecorder
pipeline ResolvedPipeline
llmClient contracts.StructuredLLMClient
}
type RunOutput struct {
@@ -70,9 +70,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if err := validateRunInput(input); err != nil {
return output, err
}
if err := r.validateRegistries(input.Pipeline); err != nil {
return output, err
}
input.pipeline = input.Prepared.resolved
input.llmClient = input.Prepared.dependencies.LLM
output.Manifest = manifestFromPipeline(input)
checkpoints := input.Checkpoints
@@ -88,27 +87,24 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
debugRecorder = NoopDebugRecorder()
}
input.Debug = debugRecorder
input.LLMClient = wrapDebugLLMClient(input.LLMClient, debugRecorder)
input.llmClient = wrapDebugLLMClient(input.llmClient, debugRecorder)
defer func() {
output.Manifest.LLMProfiles = mergeLLMProfileManifests(input.LLMProfiles, llmProfileManifests(input.LLMClient))
output.Manifest.LLMProfiles = mergeLLMProfileManifests(input.LLMProfiles, llmProfileManifests(input.llmClient))
}()
output.Warnings = append(output.Warnings, cloneWarnings(input.Warnings)...)
if err := writeDebugTimed(debugRecorder, "run.json", debugTimedEnvelope{
Stage: "run",
StartedAt: startedTime(input.StartedAt),
Payload: map[string]any{
"pipeline_id": input.Pipeline.ID,
"pipeline_digest": input.Pipeline.Digest,
"pipeline_id": input.pipeline.ID,
"pipeline_digest": input.pipeline.Digest,
"run_id": output.Manifest.RunID,
},
}); err != nil {
return failOutput(output), fmt.Errorf("write debug run artifact: %w", err)
}
adapter, err := r.registries.Inputs.Build(input.Pipeline.Input.Module)
if err != nil {
return failOutput(output), fmt.Errorf("build input adapter %q: %w", input.Pipeline.Input.Module, err)
}
adapter := input.Prepared.input
attachModuleManifestMetadata(&output, "input", adapter)
sourceCheckpoint, sourceDecision := checkpointLoader.Source(adapter.Key())
recordCheckpointEvent(&output, checkpointLoader, "source", "", adapter.Key(), sourceDecision)
@@ -122,7 +118,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
SourceID: input.SourceID,
Path: input.Path,
Raw: debugContentEnvelope(input.RawInput, sourceInputMediaType(input.Path), nil, nil),
Options: redactSensitiveMap(input.Pipeline.Input.Options),
Options: redactSensitiveMap(input.pipeline.Input.Options),
Metadata: redactSensitiveMap(input.Metadata),
},
}); err != nil {
@@ -136,8 +132,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
SourceID: input.SourceID,
Path: input.Path,
Raw: input.RawInput,
LLMProfile: input.Pipeline.Input.LLMProfile,
Options: cloneOptions(input.Pipeline.Input.Options),
LLMProfile: input.pipeline.Input.LLMProfile,
Options: cloneOptions(input.pipeline.Input.Options),
Metadata: input.Metadata,
})
if err != nil {
@@ -169,10 +165,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
output.Manifest.Metadata = manifestMetadataWithSessionID(output.Manifest.Metadata, sessionID)
output.Manifest.SourceDigests = []string{doc.Digest}
chunker, err := r.registries.Chunkers.Build(input.Pipeline.Chunk.Module)
if err != nil {
return failOutput(output), fmt.Errorf("build chunker %q: %w", input.Pipeline.Chunk.Module, err)
}
chunker := input.Prepared.chunker
attachModuleManifestMetadata(&output, "chunker", chunker)
var canonicalChunks []source.Chunk
var chunkWarnings []contracts.Warning
@@ -188,7 +181,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
"decision": chunkDecision,
"source": debugSourceDocumentEnvelope(doc),
"source_input": debugContentEnvelope(sourceInput.Content, sourceInput.MediaType, nil, nil),
"options": redactSensitiveMap(input.Pipeline.Chunk.Options),
"options": redactSensitiveMap(input.pipeline.Chunk.Options),
"metadata": redactSensitiveMap(input.Metadata),
},
}); err != nil {
@@ -204,7 +197,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
if err := checkpoints.ChunkRunning(chunker.Key(), doc.Digest); err != nil {
return failOutput(output), fmt.Errorf("write chunk checkpoint: %w", err)
}
chunksAccepted, chunkRejection, err = runWithRetry(ctx, input.Pipeline.Chunk.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
chunksAccepted, chunkRejection, err = runWithRetry(ctx, input.pipeline.Chunk.Retries, func(attempt int) (bool, *contracts.RejectedOutput, error) {
attemptStarted := time.Now().UTC()
attemptPath := path.Join("chunk", fmt.Sprintf("attempt-%02d", attempt))
attemptCtx, llmScope := withDebugLLMScope(ctx, attemptPath)
@@ -212,10 +205,10 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
Source: doc,
SourceInput: sourceInput.Clone(),
SessionID: sessionID,
References: CloneReferenceSet(input.Pipeline.ChunkReferences.ReferenceSet),
LLMClient: input.LLMClient,
LLMProfile: input.Pipeline.Chunk.LLMProfile,
Options: cloneOptions(input.Pipeline.Chunk.Options),
References: CloneReferenceSet(input.pipeline.ChunkReferences.ReferenceSet),
LLMClient: input.llmClient,
LLMProfile: input.pipeline.Chunk.LLMProfile,
Options: cloneOptions(input.pipeline.Chunk.Options),
Metadata: input.Metadata,
})
if err != nil {
@@ -254,7 +247,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}, llmScope))
return false, nil, err
}
validationWarnings, rejection, err := r.validateChunksRaw(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.Pipeline.ChunkReferences.ReferenceSet, input.LLMClient, input.Metadata, input.Pipeline.ValidatorChains, attempt, input.Debug)
validationWarnings, rejection, err := r.validateChunksRaw(attemptCtx, doc, chunker.Key(), chunks, sourceInput, sessionID, input.pipeline.ChunkReferences.ReferenceSet, input.llmClient, input.Metadata, input.Prepared.chunkValidators, attempt, input.Debug)
if err != nil || rejection != nil {
_ = writeDebugTimed(debugRecorder, attemptPath+".json", debugEnvelopeWithLLMCalls(debugTimedEnvelope{
Stage: string(StageChunk),
@@ -320,7 +313,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
}
if chunksAccepted {
for _, lane := range input.Pipeline.ArtifactLanes {
for _, lane := range input.Prepared.lanes {
if err := r.runLane(ctx, input, checkpoints, checkpointLoader, doc, sourceInput, sessionID, canonicalChunks, lane, &output); err != nil {
return failOutput(output), err
}
@@ -335,10 +328,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
populateRawOutputManifest(&output)
output.Manifest.CompletedAt = timePtr(time.Now().UTC())
encoder, err := r.registries.Outputs.Build(input.Pipeline.Output.Module)
if err != nil {
return failOutput(output), fmt.Errorf("build output encoder %q: %w", input.Pipeline.Output.Module, err)
}
encoder := input.Prepared.output
attachModuleManifestMetadata(&output, "output", encoder)
outputStarted := time.Now().UTC()
if err := writeDebugTimed(debugRecorder, "output/input.json", debugTimedEnvelope{
@@ -350,7 +340,7 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
"normalize_outputs": debugNormalizeOutputEnvelopes(output.NormalizeOutputs),
"rejected": debugRejectedOutputEnvelopes(output.Rejected),
"warnings": output.Warnings,
"options": redactSensitiveMap(input.Pipeline.Output.Options),
"options": redactSensitiveMap(input.pipeline.Output.Options),
"metadata": redactSensitiveMap(input.Metadata),
},
}); err != nil {
@@ -361,8 +351,8 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
NormalizeOutputs: cloneNormalizeOutputs(output.NormalizeOutputs),
Rejected: cloneRejectedOutputs(output.Rejected),
Warnings: output.Warnings,
LLMProfile: input.Pipeline.Output.LLMProfile,
Options: cloneOptions(input.Pipeline.Output.Options),
LLMProfile: input.pipeline.Output.LLMProfile,
Options: cloneOptions(input.pipeline.Output.Options),
Metadata: input.Metadata,
})
output.Warnings = append(output.Warnings, encoded.Warnings...)
@@ -389,19 +379,14 @@ func (r *Runner) Run(ctx context.Context, input RunInput) (output RunOutput, err
return output, nil
}
func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints CheckpointRecorder, checkpointLoader CheckpointLoader, doc *source.SourceDocument, sourceInput contracts.LLMInputMaterial, sessionID string, chunks []source.Chunk, lane ResolvedArtifactLane, output *RunOutput) error {
extractor, err := r.registries.Extractors.BuildLegacyRaw(lane.Extract.Module)
if err != nil {
return fmt.Errorf("build extractor %q for lane %q: %w", lane.Extract.Module, lane.ID, err)
}
merger, err := r.registries.Mergers.BuildLegacyRaw(lane.Merge.Module)
if err != nil {
return fmt.Errorf("build merger %q for lane %q: %w", lane.Merge.Module, lane.ID, err)
}
normalizer, err := r.registries.Normalizers.BuildLegacyRaw(lane.Normalize.Module)
if err != nil {
return fmt.Errorf("build normalizer %q for lane %q: %w", lane.Normalize.Module, lane.ID, 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 {
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))
@@ -454,7 +439,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
SourceInput: chunkInputMaterial(sourceInput, chunk),
SessionID: sessionID,
References: CloneReferenceSet(lane.ExtractReferences.ReferenceSet),
LLMClient: input.LLMClient,
LLMClient: input.llmClient,
LLMProfile: lane.Extract.LLMProfile,
Options: cloneOptions(lane.Extract.Options),
Metadata: input.Metadata,
@@ -489,11 +474,11 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
sourceInput: chunkInputMaterial(sourceInput, chunk),
sessionID: sessionID,
references: lane.ExtractReferences.ReferenceSet,
llmClient: input.LLMClient,
llmClient: input.llmClient,
schema: extractOutput.Schema,
payload: extractOutput.Payload,
metadata: input.Metadata,
chains: input.Pipeline.ValidatorChains,
prepared: prepared.extractValidators,
attempt: attempt,
debug: input.Debug,
})
@@ -606,7 +591,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
SourceInput: sourceInput.Clone(),
SessionID: sessionID,
References: CloneReferenceSet(lane.MergeReferences.ReferenceSet),
LLMClient: input.LLMClient,
LLMClient: input.llmClient,
LLMProfile: lane.Merge.LLMProfile,
Options: cloneOptions(lane.Merge.Options),
Metadata: input.Metadata,
@@ -636,12 +621,12 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
sourceInput: sourceInput.Clone(),
sessionID: sessionID,
references: lane.MergeReferences.ReferenceSet,
llmClient: input.LLMClient,
llmClient: input.llmClient,
schema: mergeOutput.Schema,
payload: mergeOutput.Payload,
extractOutputs: extractOutputs,
metadata: input.Metadata,
chains: input.Pipeline.ValidatorChains,
prepared: prepared.mergeValidators,
attempt: attempt,
debug: input.Debug,
})
@@ -762,7 +747,7 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
SourceInput: sourceInput.Clone(),
SessionID: sessionID,
References: CloneReferenceSet(lane.NormalizeReferences.ReferenceSet),
LLMClient: input.LLMClient,
LLMClient: input.llmClient,
LLMProfile: lane.Normalize.LLMProfile,
Options: cloneOptions(lane.Normalize.Options),
Metadata: input.Metadata,
@@ -792,12 +777,12 @@ func (r *Runner) runLane(ctx context.Context, input RunInput, checkpoints Checkp
sourceInput: sourceInput.Clone(),
sessionID: sessionID,
references: lane.NormalizeReferences.ReferenceSet,
llmClient: input.LLMClient,
llmClient: input.llmClient,
schema: normalizeOutput.Schema,
payload: normalizeOutput.Payload,
mergeOutput: acceptedMerge,
metadata: input.Metadata,
chains: input.Pipeline.ValidatorChains,
prepared: prepared.normalizeValidators,
attempt: attempt,
debug: input.Debug,
})
@@ -899,7 +884,7 @@ type rawValidationTarget struct {
extractOutputs []contracts.ExtractOutput
mergeOutput contracts.MergeOutput
metadata map[string]any
chains []ResolvedValidatorChain
prepared preparedValidatorChain
attempt int
debug DebugRecorder
}
@@ -951,7 +936,7 @@ func runWithRetry(ctx context.Context, retries int, run func(attempt int) (bool,
return false, lastRejection, 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, chains []ResolvedValidatorChain, attempt int, debug DebugRecorder) ([]contracts.Warning, *contracts.RejectedOutput, error) {
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,
@@ -963,26 +948,23 @@ func (r *Runner) validateChunksRaw(ctx context.Context, doc *source.SourceDocume
llmClient: llmClient,
chunks: chunks,
metadata: metadata,
chains: chains,
prepared: prepared,
attempt: attempt,
debug: debug,
})
}
func (r *Runner) validateRaw(ctx context.Context, target rawValidationTarget) ([]contracts.Warning, *contracts.RejectedOutput, error) {
chain := resolvedValidatorChain(target.stage, target.laneID, target.moduleKey, target.chains)
if len(chain.Validators) == 0 {
if len(target.prepared.validators) == 0 {
return nil, nil, nil
}
if r.registries.Validators == nil {
return nil, nil, fmt.Errorf("validator registry must not be nil")
}
var warnings []contracts.Warning
for index, validatorBinding := range chain.Validators {
validator, err := r.registries.Validators.BuildLegacyRaw(validatorBinding.Binding.Module)
if err != nil {
return nil, nil, fmt.Errorf("build validator %q: %w", validatorBinding.Binding.Module, err)
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()
@@ -1088,52 +1070,37 @@ func resolvedValidatorChain(stage ModuleStage, laneID string, moduleKey string,
}
}
func (r *Runner) validateRegistries(pipeline ResolvedPipeline) error {
if r.registries.Inputs == nil {
return fmt.Errorf("input registry must not be nil")
func validateRunInput(input RunInput) error {
if input.Prepared == nil {
return fmt.Errorf("prepared pipeline must not be nil")
}
if r.registries.Chunkers == nil {
return fmt.Errorf("chunker registry must not be nil")
}
if r.registries.Extractors == nil {
return fmt.Errorf("extractor registry must not be nil")
}
if r.registries.Mergers == nil {
return fmt.Errorf("merger registry must not be nil")
}
if r.registries.Normalizers == nil {
return fmt.Errorf("normalizer registry must not be nil")
}
if r.registries.Outputs == nil {
return fmt.Errorf("output encoder registry must not be nil")
}
return nil
return validateResolvedPipeline(input.Prepared.resolved, true)
}
func validateRunInput(input RunInput) error {
if input.Pipeline.ID == "" {
func validateResolvedPipeline(pipeline ResolvedPipeline, rejectTyped bool) error {
if pipeline.ID == "" {
return fmt.Errorf("resolved pipeline id must not be empty")
}
if input.Pipeline.Digest == "" {
if pipeline.Digest == "" {
return fmt.Errorf("resolved pipeline digest must not be empty")
}
if input.Pipeline.Input.Module == "" {
if pipeline.Input.Module == "" {
return fmt.Errorf("resolved pipeline input module must not be empty")
}
if input.Pipeline.Chunk.Module == "" {
if pipeline.Chunk.Module == "" {
return fmt.Errorf("resolved pipeline chunk module must not be empty")
}
if input.Pipeline.Output.Module == "" {
if pipeline.Output.Module == "" {
return fmt.Errorf("resolved pipeline output module must not be empty")
}
if len(input.Pipeline.ArtifactLanes) == 0 {
if len(pipeline.ArtifactLanes) == 0 {
return fmt.Errorf("resolved pipeline artifact lanes must not be empty")
}
for _, lane := range input.Pipeline.ArtifactLanes {
for _, lane := range pipeline.ArtifactLanes {
if lane.ID == "" {
return fmt.Errorf("resolved pipeline artifact lane id must not be empty")
}
if lane.ArtifactKind != "" {
if rejectTyped && lane.ArtifactKind != "" {
return fmt.Errorf("resolved pipeline lane %q uses typed artifact kind %q, which the legacy raw runner cannot execute", lane.ID, lane.ArtifactKind)
}
if lane.Extract.Module == "" {
@@ -1169,7 +1136,7 @@ func manifestFromPipeline(input RunInput) artifacts.RunManifest {
runID = fmt.Sprintf("run-%d", startedAt.UnixNano())
}
pipeline := input.Pipeline
pipeline := input.pipeline
manifest := artifacts.RunManifest{
PipelineID: pipeline.ID,
PipelineDigest: pipeline.Digest,

View File

@@ -14,17 +14,17 @@ import (
)
func TestNewAndDataTypes(t *testing.T) {
runner := New(Registries{})
runner := New()
if runner == nil {
t.Fatal("New() = nil, want runner")
}
input := RunInput{
Pipeline: resolvedPipeline(),
pipeline: resolvedPipeline(),
SourceID: "source-1",
Path: "input.txt",
RawInput: []byte("source text"),
LLMClient: fakeLLMClient{},
llmClient: fakeLLMClient{},
Metadata: map[string]any{"request": "test"},
}
output := RunOutput{
@@ -35,7 +35,7 @@ func TestNewAndDataTypes(t *testing.T) {
OutputFiles: []contracts.OutputFile{{Name: "outputs/generic.json", ContentType: "application/json", Bytes: []byte(`{}`)}},
}
if input.Pipeline.ID != "pipeline-1" || input.SourceID != "source-1" {
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 {
@@ -43,6 +43,26 @@ func TestNewAndDataTypes(t *testing.T) {
}
}
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()
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
@@ -54,10 +74,15 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
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 New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: ResolvedPipeline{Digest: "sha256:pipeline"}})
return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: ResolvedPipeline{Digest: "sha256:pipeline"}})
},
error: "pipeline id",
},
@@ -66,7 +91,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
pipeline := resolvedPipeline()
pipeline.Digest = ""
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: pipeline})
return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: pipeline})
},
error: "pipeline digest",
},
@@ -75,7 +100,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
pipeline := resolvedPipeline()
pipeline.ArtifactLanes = nil
return New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: pipeline})
return newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: pipeline})
},
error: "artifact lanes",
},
@@ -84,7 +109,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil)
registries.Inputs = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
},
error: "input registry",
},
@@ -93,7 +118,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil)
registries.Chunkers = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
},
error: "chunker registry",
},
@@ -102,7 +127,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil)
registries.Extractors = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
},
error: "extractor registry",
},
@@ -111,7 +136,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil)
registries.Mergers = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
},
error: "merger registry",
},
@@ -120,7 +145,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil)
registries.Normalizers = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
},
error: "normalizer registry",
},
@@ -129,7 +154,7 @@ func TestRunRejectsInvalidSetup(t *testing.T) {
run: func() (RunOutput, error) {
registries := newRunnerRegistries(t, nil)
registries.Outputs = nil
return New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
return newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
},
error: "output encoder registry",
},
@@ -147,7 +172,7 @@ func TestRunAllowsNilValidatorRegistryWithoutConfiguredValidators(t *testing.T)
registries := newRunnerRegistries(t, nil)
registries.Validators = nil
_, err := New(registries).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
_, err := newPreparedRunner(t, registries).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -190,9 +215,15 @@ func TestRunRejectsInputBuildParseAndInvalidSourceErrors(t *testing.T) {
modules := defaultRunnerModules()
test.configure(modules)
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
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)
}
@@ -236,9 +267,15 @@ func TestRunRejectsChunkerBuildChunkAndEmptyChunkErrors(t *testing.T) {
modules := defaultRunnerModules()
test.configure(modules)
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
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)
}
@@ -364,7 +401,7 @@ func TestRunRejectsInvalidChunks(t *testing.T) {
modules := defaultRunnerModules()
modules.chunker.chunks = test.chunks
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, test.want)
if output.Manifest.ValidationStatus != "failed" {
@@ -384,7 +421,7 @@ func TestRunAllowsPartialCoverageAndOverlappingChunks(t *testing.T) {
chunkWithUnits("chunk-1", "source-1", 1, unitWithID("u2")),
}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -419,7 +456,7 @@ func TestRunCanonicalizesChunkUnitsBeforeExtraction(t *testing.T) {
},
}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -478,7 +515,7 @@ func TestRunPreservesChunkMetadataDuringCanonicalization(t *testing.T) {
},
}
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -508,9 +545,9 @@ func TestRunExecutesChunksAndPassesChunkAndLLMClient(t *testing.T) {
modules := defaultRunnerModules()
llmClient := fakeLLMClient{}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
LLMClient: llmClient,
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
llmClient: llmClient,
Metadata: map[string]any{"request": "test"},
})
if err != nil {
@@ -546,8 +583,8 @@ func TestRunPassesSourceInputAndSessionIDToPromptCapableStages(t *testing.T) {
modules := defaultRunnerModules()
rawInput := []byte("{\"source\":\"exact bytes\"}")
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
Path: "session.json",
RawInput: rawInput,
SessionID: " explicit-session ",
@@ -619,8 +656,8 @@ func TestRunPassesSourceInputAndSessionIDToPromptCapableStages(t *testing.T) {
func TestRunDefaultsSessionIDFromParsedSourceDocumentID(t *testing.T) {
modules := defaultRunnerModules()
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
Path: "notes.unknown",
RawInput: []byte("notes"),
})
@@ -643,8 +680,8 @@ func TestRunPassesInputRequestFields(t *testing.T) {
modules := defaultRunnerModules()
metadata := map[string]any{"request": "test"}
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
SourceID: "source-1",
Path: "input.txt",
RawInput: []byte("source text"),
@@ -676,7 +713,7 @@ func TestRunPassesModuleBindingConfigToStageRequests(t *testing.T) {
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 := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -724,7 +761,7 @@ func TestRunPassesLaneReferencesToExtractorRequests(t *testing.T) {
pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].ExtractReferences.ReferenceSet = testReferenceSet("roster", "reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -745,7 +782,7 @@ func TestRunPassesMergeReferencesToMergerRequest(t *testing.T) {
pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].MergeReferences.ReferenceSet = testReferenceSet("merge_notes", "merge reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -766,7 +803,7 @@ func TestRunPassesChunkReferencesToChunkerRequest(t *testing.T) {
pipeline := resolvedPipeline()
pipeline.ChunkReferences.ReferenceSet = testReferenceSet("scene_guide", "chunk reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -787,7 +824,7 @@ func TestRunPassesNormalizeReferencesToNormalizerRequest(t *testing.T) {
pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].NormalizeReferences.ReferenceSet = testReferenceSet("normalization_notes", "normalize reference text")
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -829,11 +866,11 @@ func TestRunPassesValidationRequestContextToValidators(t *testing.T) {
rawInput := []byte("{\"source\":\"exact bytes\"}")
llmClient := fakeLLMClient{}
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: pipeline,
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: pipeline,
Path: "session.json",
RawInput: rawInput,
LLMClient: llmClient,
llmClient: llmClient,
SessionID: "session-123",
Metadata: map[string]any{"request": "test"},
})
@@ -911,7 +948,7 @@ func TestRunPassesValidationRequestContextToValidators(t *testing.T) {
}
func TestRunAllowsNilLLMClientWhenModulesDoNotUseIt(t *testing.T) {
_, err := New(newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
_, 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)
}
@@ -921,8 +958,8 @@ func TestRunIncludesInputWarnings(t *testing.T) {
modules := defaultRunnerModules()
warning := contracts.Warning{Scope: "reference", ReasonCode: "empty_reference", Message: "empty reference"}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
Warnings: []contracts.Warning{warning},
})
if err != nil {
@@ -950,7 +987,7 @@ func TestRunRecordsTopLevelModuleMetadataForSingletonModules(t *testing.T) {
"output_profile": "output-metadata",
}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -977,7 +1014,7 @@ func TestRunRecordsTopLevelModuleMetadataForSingletonModules(t *testing.T) {
func TestRunPassesPerChunkRawOutputsToMergeAndNormalize(t *testing.T) {
modules := defaultRunnerModules()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1015,7 +1052,7 @@ func TestRunPassesChunkContentAndMediaTypeToExtractors(t *testing.T) {
sourceChunkWithContent("chunk-0", 0, []byte(`{"chunk":0}`), "application/vnd.test+json"),
}
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1035,8 +1072,8 @@ func TestRunPassesChunkContentAndMediaTypeToExtractors(t *testing.T) {
func TestRunDoesNotPassCheckpointPathsToModules(t *testing.T) {
modules := defaultRunnerModules()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
_, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
Checkpoints: NoopCheckpointRecorder(),
})
if err != nil {
@@ -1128,8 +1165,8 @@ func TestRunReusesCheckpointedWorkflowOutputs(t *testing.T) {
},
}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
Checkpoint: loader,
})
if err != nil {
@@ -1181,8 +1218,8 @@ func TestRunPreservesCheckpointedExtractRejections(t *testing.T) {
reuse: map[string]bool{"extract": true},
}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
Checkpoint: loader,
})
if err != nil {
@@ -1208,7 +1245,7 @@ func TestRunOmitsRejectedExtractOutputsFromMerge(t *testing.T) {
pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1232,7 +1269,7 @@ func TestRunOmitsLaneWithNoAcceptedExtractOutputs(t *testing.T) {
pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1258,7 +1295,7 @@ func TestRunRejectedMergePreventsNormalizeForLane(t *testing.T) {
pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageMerge, "alpha", "merge", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1281,7 +1318,7 @@ func TestRunRejectedNormalizePreventsOutputForLane(t *testing.T) {
pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageNormalize, "alpha", "normalize", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1304,7 +1341,7 @@ func TestRunRetriesSameModuleInputAfterFrameworkError(t *testing.T) {
pipeline := resolvedPipeline()
pipeline.ArtifactLanes[0].Extract.Retries = 1
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1329,7 +1366,7 @@ func TestRunRetriesSameModuleInputAfterValidatorRejection(t *testing.T) {
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
pipeline.ArtifactLanes[0].Extract.Retries = 1
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1353,9 +1390,9 @@ func TestRunDebugFailedChunkAttemptReferencesScopedLLMOutput(t *testing.T) {
modules.chunker.err = errors.New("malformed structured output")
recorder := newMemoryDebugRecorder()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
LLMClient: debugResponseLLMClient{content: []byte(`{"raw":true}`), profileID: "debug-profile"},
_, 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") {
@@ -1412,9 +1449,9 @@ func TestRunDebugWritesNonJSONLLMResponseContentAsText(t *testing.T) {
modules.chunker.err = errors.New("malformed structured output")
recorder := newMemoryDebugRecorder()
_, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
LLMClient: debugResponseLLMClient{content: []byte("plain text response"), profileID: "debug-profile"},
_, 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") {
@@ -1450,7 +1487,7 @@ func TestRunStopsRetryAfterConfiguredAttemptsAndRecordsAttemptCount(t *testing.T
setResolvedValidatorChain(t, &pipeline, StageExtract, "alpha", "extract-alpha", resolvedValidatorForTest(validator))
pipeline.ArtifactLanes[0].Extract.Retries = 1
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1480,7 +1517,7 @@ func TestRunContextCancellationStopsRetries(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
output, err := New(newRunnerRegistries(t, modules)).Run(ctx, RunInput{Pipeline: pipeline})
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)
@@ -1494,8 +1531,8 @@ func TestRunContextCancellationStopsRetries(t *testing.T) {
}
func TestRunRejectsConfiguredValidators(t *testing.T) {
_, err := New(newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{
Pipeline: resolvedPipelineWithValidators("configured", "second-validator"),
_, err := newPreparedRunner(t, newRunnerRegistries(t, defaultRunnerModules())).Run(context.Background(), RunInput{
pipeline: resolvedPipelineWithValidators("configured", "second-validator"),
})
assertRunError(t, err, "extract.validators")
}
@@ -1508,7 +1545,7 @@ func TestRunCollectsStageWarnings(t *testing.T) {
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 := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1530,7 +1567,7 @@ func TestRunCollectsChunkValidatorWarnings(t *testing.T) {
pipeline := resolvedPipeline()
setResolvedValidatorChain(t, &pipeline, StageChunk, "", "chunk", resolvedValidatorForTest(validator))
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1543,7 +1580,7 @@ func TestRunCollectsChunkValidatorWarnings(t *testing.T) {
func TestRunOutputEncoderReceivesManifestAndRawOutputs(t *testing.T) {
modules := defaultRunnerModules()
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1595,7 +1632,7 @@ func TestRunRejectsUnsafeOutputFileNames(t *testing.T) {
{Name: test.fileName, ContentType: "application/json", Bytes: []byte(`{}`)},
}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, "output file name")
if output.Manifest.ValidationStatus != "failed" {
@@ -1609,7 +1646,7 @@ func TestRunReturnsFailedManifestWhenOutputEncoderFails(t *testing.T) {
modules := defaultRunnerModules()
modules.output.err = errors.New("encode failed")
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
assertRunError(t, err, "encode failed")
if output.Manifest.ValidationStatus != "failed" {
@@ -1698,7 +1735,7 @@ func TestRunManifestIncludesPipelineAndLaneDetails(t *testing.T) {
},
}
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolved})
output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: resolved})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1769,8 +1806,8 @@ func TestRunManifestIncludesRunTimingAndLLMProfiles(t *testing.T) {
{ID: "default", Provider: "scriptorium", Model: "model-a"},
}
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{
pipeline: resolvedPipeline(),
RunID: "run-test",
StartedAt: startedAt,
LLMProfiles: profiles,
@@ -1795,9 +1832,9 @@ func TestRunManifestIncludesRunTimingAndLLMProfiles(t *testing.T) {
}
func TestRunManifestIncludesProfilesReportedByLLMClient(t *testing.T) {
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{
Pipeline: resolvedPipeline(),
LLMClient: manifestReportingLLMClient{profiles: []artifacts.LLMProfileManifest{
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"},
@@ -1817,7 +1854,7 @@ func TestRunManifestIncludesProfilesReportedByLLMClient(t *testing.T) {
}
func TestRunManifestGeneratesRunIDAndTimestamps(t *testing.T) {
output, err := New(newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, nil)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1840,7 +1877,7 @@ func TestRunManifestIncludesExtractorMetadata(t *testing.T) {
"response_schema_name": "test_schema",
}
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: resolvedPipeline()})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: resolvedPipeline()})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
@@ -1871,7 +1908,7 @@ func TestRunReturnsPartialOutputWhenLaterLaneFails(t *testing.T) {
Normalize: Binding("normalize"),
})
output, err := New(newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{Pipeline: pipeline})
output, err := newPreparedRunner(t, newRunnerRegistries(t, modules)).Run(context.Background(), RunInput{pipeline: pipeline})
assertRunError(t, err, "extract failed")
if output.Manifest.ValidationStatus != "failed" {

View File

@@ -115,6 +115,21 @@ func TestResolveTypedHeterogeneousLanes(t *testing.T) {
}
}
func TestPrepareConstructsHeterogeneousTypedLanes(t *testing.T) {
catalog := typedResolutionCatalog(t, completeTypedCatalogOptions())
resolved, err := ResolvePipeline(typedResolutionProfile(), ResolveOptions{}, catalog)
if err != nil {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
prepared, err := Prepare(resolved, registriesFromModuleCatalog(catalog), ModuleDependencies{})
if err != nil {
t.Fatalf("Prepare() error = %v, want nil", err)
}
if len(prepared.ArtifactLanes) != 2 || prepared.lanes[0].typed == nil || prepared.lanes[1].typed == nil {
t.Fatalf("PreparedPipeline lanes = %#v, want two typed executors", prepared.ArtifactLanes)
}
}
func TestResolveTypedLaneRejectsIncompatibleComposition(t *testing.T) {
tests := []struct {
name string
@@ -263,17 +278,31 @@ 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 nil, nil }); err != nil {
if err := catalog.Inputs.RegisterWithSpec(ModuleSpec{Key: "typed/input", Stage: StageInput}, func() (contracts.InputAdapter, error) {
return &runnerInputAdapter{key: "typed/input", doc: validSourceDocument()}, nil
}); err != nil {
t.Fatalf("register input: %v", err)
}
if err := catalog.Chunkers.RegisterWithSpec(ModuleSpec{Key: "typed/chunk", Stage: StageChunk}, func() (contracts.Chunker, error) { return nil, nil }); err != nil {
if err := catalog.Chunkers.RegisterWithSpec(ModuleSpec{Key: "typed/chunk", Stage: StageChunk}, func() (contracts.Chunker, error) {
return &runnerChunker{key: "typed/chunk"}, 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 nil, nil }); err != nil {
if err := catalog.Outputs.RegisterWithSpec(ModuleSpec{Key: "typed/output", Stage: StageOutput}, func() (contracts.OutputEncoder, error) {
return &runnerOutputEncoder{key: "typed/output"}, nil
}); err != nil {
t.Fatalf("register output: %v", err)
}
}
func registriesFromModuleCatalog(catalog ModuleCatalog) Registries {
return Registries{
Inputs: catalog.Inputs, Chunkers: catalog.Chunkers, ArtifactCodecs: catalog.ArtifactCodecs,
Extractors: catalog.Extractors, Mergers: catalog.Mergers, Normalizers: catalog.Normalizers,
Validators: catalog.Validators, ValidatorChains: catalog.ValidatorChains, Outputs: catalog.Outputs,
}
}
func mustRegisterArtifactCodec[T any](t *testing.T, registry *ArtifactCodecRegistry, codec contracts.ArtifactCodec[T]) {
t.Helper()
if err := RegisterArtifactCodec(registry, codec); err != nil {

View File

@@ -10,6 +10,7 @@ import (
)
type LegacyRawValidatorConstructor func() (contracts.LegacyRawValidator, error)
type LegacyRawValidatorBuilder func(BuildRequest) (contracts.LegacyRawValidator, error)
type ValidatorSpec struct {
Key string `json:"key"`
@@ -32,37 +33,42 @@ const (
)
type ValidatorRegistry struct {
legacyConstructors map[string]LegacyRawValidatorConstructor
legacySpecs map[string]ValidatorSpec
typedEntries map[artifactVariantKey]typedValidatorEntry
chunkEntries map[string]chunkValidatorEntry
serializedEntries map[string]serializedValidatorEntry
legacyBuilders map[string]LegacyRawValidatorBuilder
legacyValidators map[string]OptionValidator
legacySpecs map[string]ValidatorSpec
typedEntries map[artifactVariantKey]typedValidatorEntry
chunkEntries map[string]chunkValidatorEntry
serializedEntries map[string]serializedValidatorEntry
}
type typedValidatorEntry struct {
spec ValidatorSpec
kind contracts.ArtifactKind
valueType reflect.Type
constructor func() (any, error)
spec ValidatorSpec
kind contracts.ArtifactKind
valueType reflect.Type
validateOptions OptionValidator
builder func(BuildRequest) (any, error)
}
type chunkValidatorEntry struct {
spec ValidatorSpec
constructor func() (contracts.ChunkValidator, error)
spec ValidatorSpec
validateOptions OptionValidator
builder func(BuildRequest) (contracts.ChunkValidator, error)
}
type serializedValidatorEntry struct {
spec SerializedValidatorSpec
constructor func() (contracts.SerializedValidator, error)
spec SerializedValidatorSpec
validateOptions OptionValidator
builder func(BuildRequest) (contracts.SerializedValidator, error)
}
func NewValidatorRegistry() *ValidatorRegistry {
return &ValidatorRegistry{
legacyConstructors: make(map[string]LegacyRawValidatorConstructor),
legacySpecs: make(map[string]ValidatorSpec),
typedEntries: make(map[artifactVariantKey]typedValidatorEntry),
chunkEntries: make(map[string]chunkValidatorEntry),
serializedEntries: make(map[string]serializedValidatorEntry),
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),
}
}
@@ -71,6 +77,15 @@ func (r *ValidatorRegistry) RegisterLegacyRaw(key string, constructor LegacyRawV
}
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")
}
@@ -78,24 +93,40 @@ func (r *ValidatorRegistry) RegisterLegacyRawWithSpec(spec ValidatorSpec, constr
if err != nil {
return err
}
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", normalizedSpec.Key)
if validateOptions == nil {
return fmt.Errorf("validator option validator for %q must not be nil", normalizedSpec.Key)
}
if _, ok := r.legacyConstructors[normalizedSpec.Key]; ok {
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.legacyConstructors == nil {
r.legacyConstructors = make(map[string]LegacyRawValidatorConstructor)
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.legacyConstructors[normalizedSpec.Key] = constructor
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 constructor()
})
}
func RegisterTypedValidatorBuilder[T any](registry *ValidatorRegistry, kind contracts.ArtifactKind, spec ValidatorSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.TypedValidator[T], error)) error {
if registry == nil {
return fmt.Errorf("validator registry must not be nil")
}
@@ -107,8 +138,11 @@ func RegisterTypedValidator[T any](registry *ValidatorRegistry, kind contracts.A
if kind == "" {
return fmt.Errorf("typed validator %q artifact kind must not be empty", normalizedSpec.Key)
}
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", normalizedSpec.Key)
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)
}
key := artifactVariantKey{module: normalizedSpec.Key, kind: kind}
if _, ok := registry.typedEntries[key]; ok {
@@ -118,17 +152,27 @@ func RegisterTypedValidator[T any](registry *ValidatorRegistry, kind contracts.A
registry.typedEntries = make(map[artifactVariantKey]typedValidatorEntry)
}
registry.typedEntries[key] = typedValidatorEntry{
spec: normalizedSpec,
kind: kind,
valueType: reflect.TypeFor[T](),
constructor: func() (any, error) {
return constructor()
spec: normalizedSpec,
kind: kind,
valueType: reflect.TypeFor[T](),
validateOptions: validateOptions,
builder: func(request BuildRequest) (any, error) {
return builder(cloneBuildRequest(request))
},
}
return nil
}
func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, constructor func() (contracts.ChunkValidator, error)) error {
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 constructor()
})
}
func RegisterChunkValidatorBuilder(registry *ValidatorRegistry, spec ValidatorSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.ChunkValidator, error)) error {
if registry == nil {
return fmt.Errorf("validator registry must not be nil")
}
@@ -136,8 +180,11 @@ func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, con
if err != nil {
return err
}
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", normalizedSpec.Key)
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 := registry.chunkEntries[normalizedSpec.Key]; ok {
return fmt.Errorf("chunk validator %q is already registered", normalizedSpec.Key)
@@ -145,11 +192,20 @@ func RegisterChunkValidator(registry *ValidatorRegistry, spec ValidatorSpec, con
if registry.chunkEntries == nil {
registry.chunkEntries = make(map[string]chunkValidatorEntry)
}
registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{spec: normalizedSpec, constructor: constructor}
registry.chunkEntries[normalizedSpec.Key] = chunkValidatorEntry{spec: normalizedSpec, validateOptions: validateOptions, builder: builder}
return nil
}
func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedValidatorSpec, constructor func() (contracts.SerializedValidator, error)) error {
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 constructor()
})
}
func RegisterSerializedValidatorBuilder(registry *ValidatorRegistry, spec SerializedValidatorSpec, validateOptions OptionValidator, builder func(BuildRequest) (contracts.SerializedValidator, error)) error {
if registry == nil {
return fmt.Errorf("validator registry must not be nil")
}
@@ -161,8 +217,11 @@ func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedVal
if !spec.SupportsChunks && !spec.SupportsArtifacts {
return fmt.Errorf("serialized validator %q must support chunks, artifacts, or both", spec.Key)
}
if constructor == nil {
return fmt.Errorf("validator constructor for %q must not be nil", spec.Key)
if validateOptions == nil {
return fmt.Errorf("validator option validator for %q must not be nil", spec.Key)
}
if builder == nil {
return fmt.Errorf("validator builder for %q must not be nil", spec.Key)
}
if _, ok := registry.serializedEntries[spec.Key]; ok {
return fmt.Errorf("serialized validator %q is already registered", spec.Key)
@@ -170,11 +229,15 @@ func RegisterSerializedValidator(registry *ValidatorRegistry, spec SerializedVal
if registry.serializedEntries == nil {
registry.serializedEntries = make(map[string]serializedValidatorEntry)
}
registry.serializedEntries[spec.Key] = serializedValidatorEntry{spec: spec, constructor: constructor}
registry.serializedEntries[spec.Key] = serializedValidatorEntry{spec: spec, validateOptions: validateOptions, builder: builder}
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")
}
@@ -182,11 +245,11 @@ func (r *ValidatorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawValid
if normalizedKey == "" {
return nil, fmt.Errorf("validator key must not be empty")
}
constructor, ok := r.legacyConstructors[normalizedKey]
builder, ok := r.legacyBuilders[normalizedKey]
if !ok {
return nil, fmt.Errorf("legacy raw validator %q is not registered", normalizedKey)
}
validator, err := constructor()
validator, err := builder(cloneBuildRequest(request))
if err != nil {
return nil, fmt.Errorf("build validator %q: %w", normalizedKey, err)
}
@@ -203,6 +266,37 @@ func (r *ValidatorRegistry) BuildLegacyRaw(key string) (contracts.LegacyRawValid
return validator, nil
}
func (r *ValidatorRegistry) validateOptions(resolved ResolvedValidator) error {
if r == nil {
return fmt.Errorf("validator registry must not be nil")
}
key := strings.TrimSpace(resolved.Binding.Module)
var validator OptionValidator
switch resolved.Target {
case ValidatorTargetTyped:
entry, ok := r.typedEntry(key, resolved.ArtifactKind)
if ok {
validator = entry.validateOptions
}
case ValidatorTargetChunk:
entry, ok := r.chunkEntry(key)
if ok {
validator = entry.validateOptions
}
case ValidatorTargetSerialized:
entry, ok := r.serializedEntry(key)
if ok {
validator = entry.validateOptions
}
default:
validator = r.legacyValidators[key]
}
if validator == nil {
return fmt.Errorf("validator %q construction entry is not registered", key)
}
return validateRegisteredOptions(validator, resolved.Binding.Options)
}
func (r *ValidatorRegistry) Spec(key string) (ValidatorSpec, bool) {
if r == nil {
return ValidatorSpec{}, false

View File

@@ -26,12 +26,12 @@ func TestWalkingSkeletonFixture(t *testing.T) {
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
}
output, err := New(walkingSkeletonRegistries(t)).Run(context.Background(), RunInput{
Pipeline: resolved,
output, err := newPreparedRunner(t, walkingSkeletonRegistries(t)).Run(context.Background(), RunInput{
pipeline: resolved,
SourceID: "fixture-source",
Path: "walking_skeleton_input.json",
RawInput: inputBytes,
LLMClient: llmClient,
llmClient: llmClient,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)

View File

@@ -15,6 +15,16 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
)
func runPreparedPipeline(t *testing.T, registries pipeline.Registries, resolved pipeline.ResolvedPipeline, llmClient contracts.StructuredLLMClient, input pipeline.RunInput) (pipeline.RunOutput, error) {
t.Helper()
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
return pipeline.RunOutput{}, err
}
input.Prepared = prepared
return pipeline.New().Run(context.Background(), input)
}
func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
raw := readDNDSpellsFixture(t)
expectedDoc := parseDNDSpellsFixture(t, raw)
@@ -40,10 +50,8 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
},
}
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
RawInput: raw,
LLMClient: llmClient,
output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
RawInput: raw,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
@@ -128,10 +136,8 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
},
}
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
RawInput: raw,
LLMClient: llmClient,
output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
RawInput: raw,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
@@ -169,10 +175,8 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference(t *testing.T) {
response: extractionResponse{SpellCasts: []spellCastResponse{}},
}
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
RawInput: raw,
LLMClient: llmClient,
output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
RawInput: raw,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
@@ -217,10 +221,8 @@ func TestRunnerCarriesDNDSpellCastWithInvalidSourceRefAsRawOutput(t *testing.T)
},
}
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
RawInput: raw,
LLMClient: llmClient,
output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
RawInput: raw,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
@@ -285,10 +287,8 @@ func TestRunnerCarriesMalformedDNDSpellsExtractorOutput(t *testing.T) {
resolved := resolveDNDSpellsPipeline(t)
llmClient := &fakeSpellsLLMClient{response: extractionResponse{}}
output, err := pipeline.New(dndSpellsRunnerRegistries(t)).Run(context.Background(), pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
RawInput: raw,
LLMClient: llmClient,
output, err := runPreparedPipeline(t, dndSpellsRunnerRegistries(t), resolved.ResolvedPipeline, llmClient, pipeline.RunInput{
RawInput: raw,
})
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)

View File

@@ -15,6 +15,16 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
)
func runPreparedPipeline(t *testing.T, registries pipeline.Registries, resolved pipeline.ResolvedPipeline, llmClient contracts.StructuredLLMClient, input pipeline.RunInput) (pipeline.RunOutput, error) {
t.Helper()
prepared, err := pipeline.Prepare(resolved, registries, pipeline.ModuleDependencies{LLM: llmClient})
if err != nil {
return pipeline.RunOutput{}, err
}
input.Prepared = prepared
return pipeline.New().Run(context.Background(), input)
}
func TestRunnerProcessesSeriatimInputWithFakeModules(t *testing.T) {
raw := readFixture(t, "testdata/valid_minimal.json")
expectedDoc, err := New().Parse(context.Background(), contracts.ParseRequest{Raw: raw})
@@ -28,8 +38,7 @@ func TestRunnerProcessesSeriatimInputWithFakeModules(t *testing.T) {
}
extractor := &runnerSeriatimExtractor{}
output, err := pipeline.New(seriatimRunnerRegistries(t, extractor)).Run(context.Background(), pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
output, err := runPreparedPipeline(t, seriatimRunnerRegistries(t, extractor), resolved.ResolvedPipeline, nil, pipeline.RunInput{
RawInput: raw,
})
if err != nil {
@@ -83,11 +92,9 @@ func TestRunnerFailsOnInvalidSeriatimInput(t *testing.T) {
t.Fatalf("Resolve() error = %v, want nil", err)
}
output, err := pipeline.New(seriatimRunnerRegistries(t, &runnerSeriatimExtractor{})).Run(context.Background(), pipeline.RunInput{
Pipeline: resolved.ResolvedPipeline,
RawInput: []byte(`{"metadata":{},"segments":[]}`),
SourceID: "invalid-source",
LLMClient: nil,
output, err := runPreparedPipeline(t, seriatimRunnerRegistries(t, &runnerSeriatimExtractor{}), resolved.ResolvedPipeline, nil, pipeline.RunInput{
RawInput: []byte(`{"metadata":{},"segments":[]}`),
SourceID: "invalid-source",
})
if err == nil {
t.Fatal("Run() error = nil, want invalid input error")