815 lines
28 KiB
Go
815 lines
28 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
)
|
|
|
|
func TestResolvePipelineWithExplicitModules(t *testing.T) {
|
|
catalog := newProfileCatalog(t)
|
|
registerProfileSpecs(t, catalog,
|
|
ModuleSpec{Key: "window", Stage: StageChunk, Requires: []string{"source"}, Provides: []string{"chunk"}},
|
|
ModuleSpec{Key: "record-extractor", Stage: StageExtract, Requires: []string{"chunk"}, Provides: []string{"candidate"}},
|
|
ModuleSpec{Key: "dedupe", Stage: StageMerge, Requires: []string{"candidate"}, Provides: []string{"merged"}},
|
|
ModuleSpec{Key: "canonical", Stage: StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}},
|
|
ModuleSpec{Key: "schema-check", Stage: StageValidate, Requires: []string{"normalized"}, Provides: []string{"validated"}},
|
|
ModuleSpec{Key: "ndjson", Stage: StageOutput, Requires: []string{"validated"}, Provides: []string{"encoded"}},
|
|
)
|
|
|
|
resolved, err := ResolvePipeline(PipelineProfile{
|
|
ID: " campaign ",
|
|
Input: ModuleBinding{Module: " text ", LLMProfile: " fast "},
|
|
Chunk: ModuleBinding{Module: " window ", Options: map[string]any{
|
|
"size": 10,
|
|
}},
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
" records ": {
|
|
Extract: ModuleBinding{Module: " record-extractor ", LLMProfile: " careful "},
|
|
Merge: Binding(" dedupe "),
|
|
Normalize: Binding(" canonical "),
|
|
Validators: []ModuleBinding{Binding(" schema-check ")},
|
|
},
|
|
},
|
|
Output: Binding(" ndjson "),
|
|
}, ResolveOptions{}, catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
|
|
if resolved.ID != "campaign" {
|
|
t.Fatalf("ID = %q, want campaign", resolved.ID)
|
|
}
|
|
if !reflect.DeepEqual(resolved.Input, ModuleBinding{Module: "text", LLMProfile: "fast"}) {
|
|
t.Fatalf("Input = %#v, want trimmed explicit input", resolved.Input)
|
|
}
|
|
if resolved.Chunk.Module != "window" || resolved.Chunk.LLMProfile != DefaultLLMProfile {
|
|
t.Fatalf("Chunk = %#v, want explicit module and default LLM profile", resolved.Chunk)
|
|
}
|
|
if resolved.Chunk.Options["size"] != 10 {
|
|
t.Fatalf("Chunk.Options = %#v, want size option", resolved.Chunk.Options)
|
|
}
|
|
if len(resolved.ArtifactLanes) != 1 {
|
|
t.Fatalf("len(ArtifactLanes) = %d, want 1", len(resolved.ArtifactLanes))
|
|
}
|
|
lane := resolved.ArtifactLanes[0]
|
|
if lane.ID != "records" {
|
|
t.Fatalf("lane.ID = %q, want records", lane.ID)
|
|
}
|
|
if !reflect.DeepEqual(lane.Extract, ModuleBinding{Module: "record-extractor", LLMProfile: "careful"}) {
|
|
t.Fatalf("lane.Extract = %#v, want explicit extractor", lane.Extract)
|
|
}
|
|
if lane.Merge.Module != "dedupe" || lane.Normalize.Module != "canonical" {
|
|
t.Fatalf("lane merge/normalize = %#v/%#v, want explicit modules", lane.Merge, lane.Normalize)
|
|
}
|
|
if len(lane.Validators) != 1 || lane.Validators[0].Module != "schema-check" {
|
|
t.Fatalf("lane.Validators = %#v, want schema-check", lane.Validators)
|
|
}
|
|
if resolved.Output.Module != "ndjson" {
|
|
t.Fatalf("Output.Module = %q, want ndjson", resolved.Output.Module)
|
|
}
|
|
if !strings.HasPrefix(resolved.Digest, "sha256:") {
|
|
t.Fatalf("Digest = %q, want sha256 digest", resolved.Digest)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineAppliesDefaults(t *testing.T) {
|
|
resolved, err := ResolvePipeline(PipelineProfile{
|
|
ID: "defaulted",
|
|
Input: Binding("text"),
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"events": {Extract: Binding("event-extractor")},
|
|
},
|
|
}, ResolveOptions{}, newProfileCatalog(t))
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
|
|
if resolved.Input.LLMProfile != DefaultLLMProfile {
|
|
t.Fatalf("Input.LLMProfile = %q, want %q", resolved.Input.LLMProfile, DefaultLLMProfile)
|
|
}
|
|
if !reflect.DeepEqual(resolved.Chunk, ModuleBinding{Module: DefaultChunkModule, LLMProfile: DefaultLLMProfile}) {
|
|
t.Fatalf("Chunk = %#v, want default chunk binding", resolved.Chunk)
|
|
}
|
|
if !reflect.DeepEqual(resolved.Output, ModuleBinding{Module: DefaultOutputModule, LLMProfile: DefaultLLMProfile}) {
|
|
t.Fatalf("Output = %#v, want default output binding", resolved.Output)
|
|
}
|
|
lane := resolved.ArtifactLanes[0]
|
|
if !reflect.DeepEqual(lane.Merge, ModuleBinding{Module: DefaultMergeModule, LLMProfile: DefaultLLMProfile}) {
|
|
t.Fatalf("Merge = %#v, want default merge binding", lane.Merge)
|
|
}
|
|
if !reflect.DeepEqual(lane.Normalize, ModuleBinding{Module: DefaultNormalizeModule, LLMProfile: DefaultLLMProfile}) {
|
|
t.Fatalf("Normalize = %#v, want default normalize binding", lane.Normalize)
|
|
}
|
|
if lane.Extract.LLMProfile != DefaultLLMProfile {
|
|
t.Fatalf("Extract.LLMProfile = %q, want %q", lane.Extract.LLMProfile, DefaultLLMProfile)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineSelectsOnlyRequestedLanes(t *testing.T) {
|
|
profile := multiLaneProfile()
|
|
resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{" summaries ", "events", "summaries"}}, newProfileCatalog(t))
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
|
|
got := laneIDs(resolved.ArtifactLanes)
|
|
want := []string{"events", "summaries"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("lane IDs = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineAppliesReferenceBindings(t *testing.T) {
|
|
profile := multiLaneProfile()
|
|
profile.References = map[string]string{
|
|
" roster ": " ./shared-roster.yml ",
|
|
}
|
|
lane := profile.Artifacts["events"]
|
|
lane.References = map[string]string{
|
|
"roster": "./lane-roster.yml",
|
|
" lore ": " ./lore.md ",
|
|
}
|
|
profile.Artifacts["events"] = lane
|
|
|
|
catalog := newProfileCatalogWithOverride(t, ModuleSpec{
|
|
Key: "event-extractor",
|
|
Stage: StageExtract,
|
|
Requires: []string{"chunk"},
|
|
Provides: []string{"candidate"},
|
|
ReferenceSlots: []contracts.ReferenceSlot{
|
|
{Name: "roster", Required: true},
|
|
{Name: "lore"},
|
|
},
|
|
})
|
|
resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events", "summaries"}}, catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
|
|
events := resolvedLane(t, resolved.ArtifactLanes, "events")
|
|
if events.ExtractReferences.Stage != StageExtract || events.ExtractReferences.LaneID != "events" || events.ExtractReferences.Module != "event-extractor" {
|
|
t.Fatalf("extract reference target = %#v, want event extractor target", events.ExtractReferences)
|
|
}
|
|
if events.NormalizeReferences.Stage != StageNormalize || events.NormalizeReferences.LaneID != "events" || events.NormalizeReferences.Module != DefaultNormalizeModule {
|
|
t.Fatalf("normalize reference target = %#v, want event normalizer target", events.NormalizeReferences)
|
|
}
|
|
if resolved.ChunkReferences.Stage != StageChunk || resolved.ChunkReferences.Module != DefaultChunkModule {
|
|
t.Fatalf("chunk reference target = %#v, want chunk target", resolved.ChunkReferences)
|
|
}
|
|
want := []ReferenceBinding{
|
|
{LaneID: "events", SlotName: "lore", Source: "./lore.md", BindingSource: contracts.ReferenceBindingSourceConfig},
|
|
{LaneID: "events", SlotName: "roster", Source: "./lane-roster.yml", BindingSource: contracts.ReferenceBindingSourceConfig},
|
|
}
|
|
if !reflect.DeepEqual(events.ExtractReferences.Bindings, want) {
|
|
t.Fatalf("events references = %#v, want %#v", events.ExtractReferences.Bindings, want)
|
|
}
|
|
summaries := resolvedLane(t, resolved.ArtifactLanes, "summaries")
|
|
if len(summaries.ExtractReferences.Bindings) != 0 {
|
|
t.Fatalf("summaries references = %#v, want none", summaries.ExtractReferences.Bindings)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineAllowsPipelineReferenceDeclaredOnlyByUnselectedLane(t *testing.T) {
|
|
profile := multiLaneProfile()
|
|
profile.References = map[string]string{"notes_context": "./notes.md"}
|
|
catalog := newProfileCatalogWithOverride(t, ModuleSpec{
|
|
Key: "note-extractor",
|
|
Stage: StageExtract,
|
|
Requires: []string{"chunk"},
|
|
Provides: []string{"candidate"},
|
|
ReferenceSlots: []contracts.ReferenceSlot{
|
|
{Name: "notes_context"},
|
|
},
|
|
})
|
|
|
|
resolved, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
if refs := resolved.ArtifactLanes[0].ExtractReferences.Bindings; len(refs) != 0 {
|
|
t.Fatalf("selected lane references = %#v, want none", refs)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineRejectsPipelineReferenceNotDeclaredByAnyLane(t *testing.T) {
|
|
profile := multiLaneProfile()
|
|
profile.References = map[string]string{"missing": "./missing.md"}
|
|
|
|
_, err := ResolvePipeline(profile, ResolveOptions{Only: []string{"events"}}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "multi", "reference slot", "missing", "not declared")
|
|
}
|
|
|
|
func TestResolvePipelineRejectsUndeclaredReferenceSlot(t *testing.T) {
|
|
profile := baselineProfile()
|
|
lane := profile.Artifacts["events"]
|
|
lane.References = map[string]string{"missing": "./missing.yml"}
|
|
profile.Artifacts["events"] = lane
|
|
|
|
_, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "events", "missing", "not declared")
|
|
}
|
|
|
|
func TestResolvePipelineRequiresBoundReferenceSlotsForSelectedLanes(t *testing.T) {
|
|
catalog := newProfileCatalogWithOverride(t, ModuleSpec{
|
|
Key: "event-extractor",
|
|
Stage: StageExtract,
|
|
Requires: []string{"chunk"},
|
|
Provides: []string{"candidate"},
|
|
ReferenceSlots: []contracts.ReferenceSlot{
|
|
{Name: "roster", Required: true},
|
|
},
|
|
})
|
|
|
|
if _, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{"notes"}}, catalog); err != nil {
|
|
t.Fatalf("ResolvePipeline(unselected required slot) error = %v, want nil", err)
|
|
}
|
|
|
|
_, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{"events"}}, catalog)
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline(selected required slot) error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "events", "required", "roster", "not bound")
|
|
}
|
|
|
|
func TestResolvePipelineReferenceUnbindCanLeaveRequiredSlotMissing(t *testing.T) {
|
|
profile := baselineProfile()
|
|
profile.References = map[string]string{"roster": "./roster.yml"}
|
|
catalog := newProfileCatalogWithOverride(t, ModuleSpec{
|
|
Key: "event-extractor",
|
|
Stage: StageExtract,
|
|
Requires: []string{"chunk"},
|
|
Provides: []string{"candidate"},
|
|
ReferenceSlots: []contracts.ReferenceSlot{
|
|
{Name: "roster", Required: true},
|
|
},
|
|
})
|
|
|
|
_, err := ResolvePipeline(profile, ResolveOptions{
|
|
ReferenceUnbinds: []ReferenceUnbind{{LaneID: "events", SlotName: "roster"}},
|
|
}, catalog)
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "events", "required", "roster", "not bound")
|
|
}
|
|
|
|
func TestResolvePipelineUsesReferenceSlotsFromSpecWithoutConstructingExtractor(t *testing.T) {
|
|
profile := baselineProfile()
|
|
profile.References = map[string]string{"roster": "./roster.yml"}
|
|
catalog := emptyProfileCatalog()
|
|
for _, spec := range defaultProfileSpecs() {
|
|
if spec.Key != "event-extractor" {
|
|
registerProfileSpecs(t, catalog, spec)
|
|
}
|
|
}
|
|
if err := catalog.Extractors.RegisterWithSpec(ModuleSpec{
|
|
Key: "event-extractor",
|
|
Stage: StageExtract,
|
|
Requires: []string{"chunk"},
|
|
Provides: []string{"candidate"},
|
|
ReferenceSlots: []contracts.ReferenceSlot{
|
|
{Name: "roster", Required: true},
|
|
},
|
|
}, func() (contracts.Extractor, error) {
|
|
return nil, errors.New("constructor should not run")
|
|
}); err != nil {
|
|
t.Fatalf("RegisterWithSpec() error = %v, want nil", err)
|
|
}
|
|
|
|
resolved, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
if got := resolved.ArtifactLanes[0].ExtractReferences.Bindings[0].Source; got != "./roster.yml" {
|
|
t.Fatalf("reference source = %q, want ./roster.yml", got)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineRejectsUnknownOnlyLane(t *testing.T) {
|
|
_, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{"missing"}}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "pipeline", "missing", "not declared")
|
|
}
|
|
|
|
func TestResolvePipelineRejectsEmptyOnlyLane(t *testing.T) {
|
|
_, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{Only: []string{" \t"}}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "pipeline", "artifact lane", "empty")
|
|
}
|
|
|
|
func TestResolvePipelineRejectsEmptyArtifactSet(t *testing.T) {
|
|
_, err := ResolvePipeline(PipelineProfile{
|
|
ID: "empty",
|
|
Input: Binding("text"),
|
|
Artifacts: map[string]ArtifactLaneProfile{},
|
|
}, ResolveOptions{}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "empty", "artifact lane")
|
|
}
|
|
|
|
func TestResolvePipelineRejectsEmptyPipelineID(t *testing.T) {
|
|
_, err := ResolvePipeline(PipelineProfile{
|
|
ID: " ",
|
|
Input: Binding("text"),
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"events": {Extract: Binding("event-extractor")},
|
|
},
|
|
}, ResolveOptions{}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "pipeline id", "empty")
|
|
}
|
|
|
|
func TestResolvePipelineRejectsMissingInput(t *testing.T) {
|
|
_, err := ResolvePipeline(PipelineProfile{
|
|
ID: "missing-input",
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"events": {Extract: Binding("event-extractor")},
|
|
},
|
|
}, ResolveOptions{}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, "missing-input", "input", "empty")
|
|
}
|
|
|
|
func TestResolvePipelineRejectsUnknownModuleKeys(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
profile PipelineProfile
|
|
want []string
|
|
}{
|
|
{
|
|
name: "input",
|
|
profile: PipelineProfile{
|
|
ID: "unknown-input",
|
|
Input: Binding("missing-input"),
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"events": {Extract: Binding("event-extractor")},
|
|
},
|
|
},
|
|
want: []string{"unknown-input", "input", "missing-input"},
|
|
},
|
|
{
|
|
name: "chunk",
|
|
profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
|
|
profile.Chunk = Binding("missing-chunk")
|
|
return profile
|
|
}),
|
|
want: []string{"baseline", "chunk", "missing-chunk"},
|
|
},
|
|
{
|
|
name: "extract",
|
|
profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
|
|
lane := profile.Artifacts["events"]
|
|
lane.Extract = Binding("missing-extractor")
|
|
profile.Artifacts["events"] = lane
|
|
return profile
|
|
}),
|
|
want: []string{"baseline", "events", "extract", "missing-extractor"},
|
|
},
|
|
{
|
|
name: "merge",
|
|
profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
|
|
lane := profile.Artifacts["events"]
|
|
lane.Merge = Binding("missing-merge")
|
|
profile.Artifacts["events"] = lane
|
|
return profile
|
|
}),
|
|
want: []string{"baseline", "events", "merge", "missing-merge"},
|
|
},
|
|
{
|
|
name: "normalize",
|
|
profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
|
|
lane := profile.Artifacts["events"]
|
|
lane.Normalize = Binding("missing-normalize")
|
|
profile.Artifacts["events"] = lane
|
|
return profile
|
|
}),
|
|
want: []string{"baseline", "events", "normalize", "missing-normalize"},
|
|
},
|
|
{
|
|
name: "validate",
|
|
profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
|
|
lane := profile.Artifacts["events"]
|
|
lane.Validators = []ModuleBinding{Binding("missing-validator")}
|
|
profile.Artifacts["events"] = lane
|
|
return profile
|
|
}),
|
|
want: []string{"baseline", "events", "validate", "missing-validator"},
|
|
},
|
|
{
|
|
name: "output",
|
|
profile: withProfileChange(func(profile PipelineProfile) PipelineProfile {
|
|
profile.Output = Binding("missing-output")
|
|
return profile
|
|
}),
|
|
want: []string{"baseline", "output", "missing-output"},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
_, err := ResolvePipeline(test.profile, ResolveOptions{}, newProfileCatalog(t))
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, test.want...)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineRejectsMissingCapabilities(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
spec ModuleSpec
|
|
want []string
|
|
}{
|
|
{
|
|
name: "input",
|
|
spec: ModuleSpec{Key: "text", Stage: StageInput, Requires: []string{"raw"}},
|
|
want: []string{"baseline", "input", "text", "raw"},
|
|
},
|
|
{
|
|
name: "chunk",
|
|
spec: ModuleSpec{Key: "generic", Stage: StageChunk, Requires: []string{"missing"}},
|
|
want: []string{"baseline", "chunk", "generic", "missing"},
|
|
},
|
|
{
|
|
name: "extract",
|
|
spec: ModuleSpec{Key: "event-extractor", Stage: StageExtract, Requires: []string{"missing"}},
|
|
want: []string{"baseline", "events", "extract", "event-extractor", "missing"},
|
|
},
|
|
{
|
|
name: "merge",
|
|
spec: ModuleSpec{Key: "appendorder", Stage: StageMerge, Requires: []string{"missing"}},
|
|
want: []string{"baseline", "events", "merge", "appendorder", "missing"},
|
|
},
|
|
{
|
|
name: "normalize",
|
|
spec: ModuleSpec{Key: "noop", Stage: StageNormalize, Requires: []string{"missing"}},
|
|
want: []string{"baseline", "events", "normalize", "noop", "missing"},
|
|
},
|
|
{
|
|
name: "validate",
|
|
spec: ModuleSpec{Key: "grounded", Stage: StageValidate, Requires: []string{"missing"}},
|
|
want: []string{"baseline", "events", "validate", "grounded", "missing"},
|
|
},
|
|
{
|
|
name: "output",
|
|
spec: ModuleSpec{Key: "json", Stage: StageOutput, Requires: []string{"missing"}},
|
|
want: []string{"baseline", "output", "json", "missing"},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
catalog := newProfileCatalogWithOverride(t, test.spec)
|
|
profile := baselineProfile()
|
|
lane := profile.Artifacts["events"]
|
|
lane.Validators = []ModuleBinding{Binding("grounded")}
|
|
profile.Artifacts["events"] = lane
|
|
|
|
_, err := ResolvePipeline(profile, ResolveOptions{}, catalog)
|
|
if err == nil {
|
|
t.Fatal("ResolvePipeline() error = nil, want error")
|
|
}
|
|
assertErrorContains(t, err, test.want...)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineOrdersLanesDeterministically(t *testing.T) {
|
|
resolved, err := ResolvePipeline(multiLaneProfile(), ResolveOptions{}, newProfileCatalog(t))
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
|
|
got := laneIDs(resolved.ArtifactLanes)
|
|
want := []string{"events", "notes", "summaries"}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("lane IDs = %#v, want %#v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineDigestIsDeterministicForEquivalentMaps(t *testing.T) {
|
|
left := PipelineProfile{
|
|
ID: "digest",
|
|
Input: Binding("text"),
|
|
Output: Binding("json"),
|
|
Chunk: ModuleBinding{Module: "generic", Options: map[string]any{"b": 2, "a": 1}},
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"events": {Extract: Binding("event-extractor")},
|
|
"notes": {Extract: Binding("note-extractor")},
|
|
},
|
|
}
|
|
right := PipelineProfile{
|
|
ID: "digest",
|
|
Input: Binding("text"),
|
|
Output: Binding("json"),
|
|
Chunk: ModuleBinding{Module: "generic", Options: map[string]any{"a": 1, "b": 2}},
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"notes": {Extract: Binding("note-extractor")},
|
|
"events": {Extract: Binding("event-extractor")},
|
|
},
|
|
}
|
|
|
|
leftResolved, err := ResolvePipeline(left, ResolveOptions{}, newProfileCatalog(t))
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline(left) error = %v, want nil", err)
|
|
}
|
|
rightResolved, err := ResolvePipeline(right, ResolveOptions{}, newProfileCatalog(t))
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline(right) error = %v, want nil", err)
|
|
}
|
|
|
|
if leftResolved.Digest != rightResolved.Digest {
|
|
t.Fatalf("digests differ for equivalent profiles: %q != %q", leftResolved.Digest, rightResolved.Digest)
|
|
}
|
|
}
|
|
|
|
func TestResolvePipelineDigestChangesWhenBindingChanges(t *testing.T) {
|
|
left := baselineProfile()
|
|
right := baselineProfile()
|
|
right.Chunk = Binding("window")
|
|
catalog := newProfileCatalog(t)
|
|
registerProfileSpecs(t, catalog, ModuleSpec{Key: "window", Stage: StageChunk, Requires: []string{"source"}, Provides: []string{"chunk"}})
|
|
|
|
leftResolved, err := ResolvePipeline(left, ResolveOptions{}, catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline(left) error = %v, want nil", err)
|
|
}
|
|
rightResolved, err := ResolvePipeline(right, ResolveOptions{}, catalog)
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline(right) error = %v, want nil", err)
|
|
}
|
|
|
|
if leftResolved.Digest == rightResolved.Digest {
|
|
t.Fatalf("digest = %q for both profiles, want changed digest", leftResolved.Digest)
|
|
}
|
|
}
|
|
|
|
func TestBindingTrimsModuleAndLeavesResolutionFieldsEmpty(t *testing.T) {
|
|
binding := Binding(" module ")
|
|
if binding.Module != "module" {
|
|
t.Fatalf("Module = %q, want module", binding.Module)
|
|
}
|
|
if binding.LLMProfile != "" {
|
|
t.Fatalf("LLMProfile = %q, want empty", binding.LLMProfile)
|
|
}
|
|
if binding.Options != nil {
|
|
t.Fatalf("Options = %#v, want nil", binding.Options)
|
|
}
|
|
}
|
|
|
|
func TestResolvedPipelineDigestExcludesDigestField(t *testing.T) {
|
|
resolved, err := ResolvePipeline(baselineProfile(), ResolveOptions{}, newProfileCatalog(t))
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
changed := resolved
|
|
changed.Digest = "sha256:changed"
|
|
|
|
leftDigest, err := resolvedPipelineDigest(resolved)
|
|
if err != nil {
|
|
t.Fatalf("resolvedPipelineDigest(resolved) error = %v, want nil", err)
|
|
}
|
|
rightDigest, err := resolvedPipelineDigest(changed)
|
|
if err != nil {
|
|
t.Fatalf("resolvedPipelineDigest(changed) error = %v, want nil", err)
|
|
}
|
|
if leftDigest != rightDigest {
|
|
t.Fatalf("digest with changed digest field = %q, want %q", rightDigest, leftDigest)
|
|
}
|
|
}
|
|
|
|
func baselineProfile() PipelineProfile {
|
|
return PipelineProfile{
|
|
ID: "baseline",
|
|
Input: Binding("text"),
|
|
Artifacts: map[string]ArtifactLaneProfile{
|
|
"events": {Extract: Binding("event-extractor")},
|
|
},
|
|
}
|
|
}
|
|
|
|
func multiLaneProfile() PipelineProfile {
|
|
profile := baselineProfile()
|
|
profile.ID = "multi"
|
|
profile.Artifacts = map[string]ArtifactLaneProfile{
|
|
"summaries": {Extract: Binding("note-extractor")},
|
|
"events": {Extract: Binding("event-extractor")},
|
|
"notes": {Extract: Binding("note-extractor")},
|
|
}
|
|
return profile
|
|
}
|
|
|
|
func withProfileChange(change func(PipelineProfile) PipelineProfile) PipelineProfile {
|
|
return change(baselineProfile())
|
|
}
|
|
|
|
func laneIDs(lanes []ResolvedArtifactLane) []string {
|
|
ids := make([]string, 0, len(lanes))
|
|
for _, lane := range lanes {
|
|
ids = append(ids, lane.ID)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func resolvedLane(t *testing.T, lanes []ResolvedArtifactLane, laneID string) ResolvedArtifactLane {
|
|
t.Helper()
|
|
for _, lane := range lanes {
|
|
if lane.ID == laneID {
|
|
return lane
|
|
}
|
|
}
|
|
t.Fatalf("lane %q not found in %#v", laneID, laneIDs(lanes))
|
|
return ResolvedArtifactLane{}
|
|
}
|
|
|
|
func assertErrorContains(t *testing.T, err error, values ...string) {
|
|
t.Helper()
|
|
|
|
message := err.Error()
|
|
for _, value := range values {
|
|
if !strings.Contains(message, value) {
|
|
t.Fatalf("error = %q, want substring %q", message, value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func newProfileCatalog(t *testing.T) ModuleCatalog {
|
|
t.Helper()
|
|
|
|
catalog := emptyProfileCatalog()
|
|
registerProfileSpecs(t, catalog, defaultProfileSpecs()...)
|
|
return catalog
|
|
}
|
|
|
|
func newProfileCatalogWithOverride(t *testing.T, override ModuleSpec) ModuleCatalog {
|
|
t.Helper()
|
|
|
|
specs := defaultProfileSpecs()
|
|
for index, spec := range specs {
|
|
if spec.Stage == override.Stage && spec.Key == override.Key {
|
|
specs[index] = override
|
|
catalog := emptyProfileCatalog()
|
|
registerProfileSpecs(t, catalog, specs...)
|
|
return catalog
|
|
}
|
|
}
|
|
|
|
catalog := emptyProfileCatalog()
|
|
registerProfileSpecs(t, catalog, specs...)
|
|
registerProfileSpecs(t, catalog, override)
|
|
return catalog
|
|
}
|
|
|
|
func emptyProfileCatalog() ModuleCatalog {
|
|
return ModuleCatalog{
|
|
Inputs: NewInputAdapterRegistry(),
|
|
Chunkers: NewChunkerRegistry(),
|
|
Extractors: NewExtractorRegistry(),
|
|
Mergers: NewMergerRegistry(),
|
|
Normalizers: NewNormalizerRegistry(),
|
|
Validators: NewValidatorRegistry(),
|
|
Outputs: NewOutputEncoderRegistry(),
|
|
}
|
|
}
|
|
|
|
func defaultProfileSpecs() []ModuleSpec {
|
|
return []ModuleSpec{
|
|
ModuleSpec{Key: "text", Stage: StageInput, Provides: []string{"source"}},
|
|
ModuleSpec{Key: "generic", Stage: StageChunk, Requires: []string{"source"}, Provides: []string{"chunk"}},
|
|
ModuleSpec{Key: "event-extractor", Stage: StageExtract, Requires: []string{"chunk"}, Provides: []string{"candidate"}},
|
|
ModuleSpec{Key: "note-extractor", Stage: StageExtract, Requires: []string{"chunk"}, Provides: []string{"candidate"}},
|
|
ModuleSpec{Key: "appendorder", Stage: StageMerge, Requires: []string{"candidate"}, Provides: []string{"merged"}},
|
|
ModuleSpec{Key: "noop", Stage: StageNormalize, Requires: []string{"merged"}, Provides: []string{"normalized"}},
|
|
ModuleSpec{Key: "grounded", Stage: StageValidate, Requires: []string{"normalized"}, Provides: []string{"validated"}},
|
|
ModuleSpec{Key: "json", Stage: StageOutput, Requires: []string{"normalized"}, Provides: []string{"encoded"}},
|
|
}
|
|
}
|
|
|
|
func registerProfileSpecs(t *testing.T, catalog ModuleCatalog, specs ...ModuleSpec) {
|
|
t.Helper()
|
|
|
|
for _, spec := range specs {
|
|
switch spec.Stage {
|
|
case StageInput:
|
|
if err := catalog.Inputs.RegisterWithSpec(spec, profileInputConstructor(spec.Key)); err != nil {
|
|
t.Fatalf("register input spec %#v: %v", spec, err)
|
|
}
|
|
case StageChunk:
|
|
if err := catalog.Chunkers.RegisterWithSpec(spec, profileChunkerConstructor(spec.Key)); err != nil {
|
|
t.Fatalf("register chunk spec %#v: %v", spec, err)
|
|
}
|
|
case StageExtract:
|
|
if err := catalog.Extractors.RegisterWithSpec(spec, profileExtractorConstructor(spec.Key)); err != nil {
|
|
t.Fatalf("register extractor spec %#v: %v", spec, err)
|
|
}
|
|
case StageMerge:
|
|
if err := catalog.Mergers.RegisterWithSpec(spec, profileMergerConstructor(spec.Key)); err != nil {
|
|
t.Fatalf("register merger spec %#v: %v", spec, err)
|
|
}
|
|
case StageNormalize:
|
|
if err := catalog.Normalizers.RegisterWithSpec(spec, profileNormalizerConstructor(spec.Key)); err != nil {
|
|
t.Fatalf("register normalizer spec %#v: %v", spec, err)
|
|
}
|
|
case StageValidate:
|
|
if err := catalog.Validators.RegisterWithSpec(spec, profileValidatorConstructor(spec.Key)); err != nil {
|
|
t.Fatalf("register validator spec %#v: %v", spec, err)
|
|
}
|
|
case StageOutput:
|
|
if err := catalog.Outputs.RegisterWithSpec(spec, profileOutputConstructor(spec.Key)); err != nil {
|
|
t.Fatalf("register output spec %#v: %v", spec, err)
|
|
}
|
|
default:
|
|
t.Fatalf("unsupported spec stage %q", spec.Stage)
|
|
}
|
|
}
|
|
}
|
|
|
|
func profileInputConstructor(key string) InputAdapterConstructor {
|
|
return func() (contracts.InputAdapter, error) {
|
|
return profileInputAdapter{key: key}, nil
|
|
}
|
|
}
|
|
|
|
type profileInputAdapter struct {
|
|
key string
|
|
}
|
|
|
|
func (adapter profileInputAdapter) Key() string {
|
|
return adapter.key
|
|
}
|
|
|
|
func (adapter profileInputAdapter) Parse(ctx context.Context, req contracts.ParseRequest) (*source.SourceDocument, error) {
|
|
return &source.SourceDocument{}, nil
|
|
}
|
|
|
|
func profileChunkerConstructor(key string) ChunkerConstructor {
|
|
return func() (contracts.Chunker, error) {
|
|
return registryChunker{key: key}, nil
|
|
}
|
|
}
|
|
|
|
func profileExtractorConstructor(key string) ExtractorConstructor {
|
|
return func() (contracts.Extractor, error) {
|
|
return registryFakeExtractor{key: key}, nil
|
|
}
|
|
}
|
|
|
|
func profileMergerConstructor(key string) MergerConstructor {
|
|
return func() (contracts.Merger, error) {
|
|
return registryMerger{key: key}, nil
|
|
}
|
|
}
|
|
|
|
func profileNormalizerConstructor(key string) NormalizerConstructor {
|
|
return func() (contracts.Normalizer, error) {
|
|
return registryNormalizer{key: key}, nil
|
|
}
|
|
}
|
|
|
|
func profileValidatorConstructor(key string) ValidatorConstructor {
|
|
return func() (contracts.Validator, error) {
|
|
return registryValidator{name: key}, nil
|
|
}
|
|
}
|
|
|
|
func profileOutputConstructor(key string) OutputEncoderConstructor {
|
|
return func() (contracts.OutputEncoder, error) {
|
|
return registryOutputEncoder{key: key}, nil
|
|
}
|
|
}
|
|
|
|
func TestResolvedPipelineCanMarshalToCanonicalJSON(t *testing.T) {
|
|
resolved, err := ResolvePipeline(baselineProfile(), ResolveOptions{}, newProfileCatalog(t))
|
|
if err != nil {
|
|
t.Fatalf("ResolvePipeline() error = %v, want nil", err)
|
|
}
|
|
if _, err := json.Marshal(resolved); err != nil {
|
|
t.Fatalf("json.Marshal(resolved) error = %v, want nil", err)
|
|
}
|
|
}
|