diff --git a/docs/config.md b/docs/config.md index 80e7cef1..52a30324 100644 --- a/docs/config.md +++ b/docs/config.md @@ -91,6 +91,42 @@ append a pipeline's `steps` sequence. An overlay replaces sequences as defined by ConfigKit, so changing an individual ordered binding requires supplying the whole replacement sequence. +## Sibling Inheritance + +ConfigKit v0.5.0 supports native `extends` composition in mappings. A directive +names exactly one immediate sibling in the same mapping; it is not a path or a +cross-scope lookup. Imports, the selected overlay, and explicit overrides are +composed before inheritance, which runs before typed decoding and normal +Notarius validation. Every definition, including a parent and definitions not +selected for a run, remains subject to ordinary validation. + +Child mappings recursively overlay parent mappings. A child sequence, scalar, +or null replaces the inherited value. This makes pipeline inheritance useful +for reusing a complete pipeline while replacing whole ordered lists: + +~~~yaml +pipelines: + production: + input: {module: seriatim} + llm_profile: dnd-extraction + # Other complete pipeline settings and ordered steps... + development: + extends: production + structured_output_repair_attempts: 0 +~~~ + +Pipeline names are siblings within `pipelines`; nested mappings such as named +artifact lanes also have their own sibling namespace. Step entries are a +sequence, so a step cannot extend another step. Replacing `steps` or a validator +chain supplies the complete replacement sequence; entries are not appended or +merged by identifier. + +`extends` is now a reserved key in every input mapping, including dynamic +module options and named maps. Existing ordinary data or entry names using that +key must be renamed along with any consumer that reads them. Configuration +overlays, pipeline inheritance, and PromptKit profile `base_profile` +inheritance are separate mechanisms. + ## PromptKit Profiles The optional **promptkit** object selects one source of profile definitions and diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3101541b..725c84da 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -108,6 +108,8 @@ adjustments. Leave new inheritance coverage to the following stages. ## Stage 2 — Prove Pipeline Composition And Runtime Conversion +> Completion: complete. + Goal: establish whole-pipeline reuse through the real configuration boundary. Inspect `load.go`, `document.go`, `loaded_config.go`, `pipeline_document.go`, diff --git a/internal/core/config/inheritance_test.go b/internal/core/config/inheritance_test.go new file mode 100644 index 00000000..55af0387 --- /dev/null +++ b/internal/core/config/inheritance_test.go @@ -0,0 +1,133 @@ +package config + +import ( + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestPipelineInheritancePreservesCompositionAndReplacesSteps(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + childSteps string + wantStepIDs []string + }{ + { + name: "inherits ordered steps", + wantStepIDs: []string{"prepare", "summarize"}, + }, + { + name: "replaces ordered steps", + childSteps: ` + steps: + - id: replacement + artifacts: + summary: + extract: {module: extract}`, + wantStepIDs: []string{"replacement"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + source := `version: 5 +pipelines: + parent: + input: {module: input} + structured_output_repair_attempts: 2 + steps: + - id: prepare + artifacts: + score: + extract: {module: extract, retries: 2} + - id: summarize + artifacts: + notes: + extract: {module: extract} + child: + extends: parent + llm_profile: local + structured_output_repair_attempts: 0` + test.childSteps + "\n" + loaded, err := loadConfigSource(configkit.Bytes("inheritance.yml", []byte(source)), LoadOptions{}) + if err != nil { + t.Fatalf("loadConfigSource() error = %v", err) + } + + parent := loaded.Config.Pipelines["parent"] + child := loaded.Config.Pipelines["child"] + if parent.Input.Module != "input" || child.Input.Module != "input" { + t.Fatalf("parent/child inputs = %#v / %#v", parent.Input, child.Input) + } + if child.LLMProfile != "local" || + child.StructuredOutputRepairAttempts == nil || *child.StructuredOutputRepairAttempts != 0 { + t.Fatalf("child overrides = %#v", child) + } + if parent.StructuredOutputRepairAttempts == nil || *parent.StructuredOutputRepairAttempts != 2 { + t.Fatalf("parent settings changed: %#v", parent) + } + if len(child.Steps) != len(test.wantStepIDs) { + t.Fatalf("child steps = %#v, want %v", child.Steps, test.wantStepIDs) + } + for i, want := range test.wantStepIDs { + if child.Steps[i].ID != want { + t.Fatalf("child step %d = %q, want %q", i, child.Steps[i].ID, want) + } + } + if test.childSteps == "" && child.Steps[0].Artifacts["score"].Extract.Module != "extract" { + t.Fatalf("inherited first artifact = %#v", child.Steps[0].Artifacts["score"]) + } + + for _, id := range []string{"parent", "child"} { + resolved, err := loaded.Config.Resolve(ResolveInput{PipelineID: id, Catalog: effectiveCatalog(t)}) + if err != nil { + t.Fatalf("Resolve(%q) error = %v", id, err) + } + if resolved.ResolvedPipeline.ID != id { + t.Fatalf("resolved pipeline ID = %q, want %q", resolved.ResolvedPipeline.ID, id) + } + wantResolvedSteps := len(test.wantStepIDs) + if id == "parent" { + wantResolvedSteps = 2 + } + if len(resolved.ResolvedPipeline.Steps) != wantResolvedSteps { + t.Fatalf("resolved %q steps = %#v", id, resolved.ResolvedPipeline.Steps) + } + if id == "child" && resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].Extract.LLMProfile != "local" { + t.Fatalf("resolved child profile = %q", resolved.ResolvedPipeline.Steps[0].ArtifactLanes[0].Extract.LLMProfile) + } + } + }) + } +} + +func TestArtifactLaneInheritanceUsesSiblingMappingComposition(t *testing.T) { + t.Parallel() + + loaded, err := loadConfigSource(configkit.Bytes("lanes.yml", []byte(`version: 5 +pipelines: + session: + input: {module: input} + artifacts: + base: + extract: {module: extract, retries: 2} + derived: + extends: base + extract: {retries: 4} +`)), LoadOptions{}) + if err != nil { + t.Fatalf("loadConfigSource() error = %v", err) + } + + lanes := loaded.Config.Pipelines["session"].Artifacts + if lanes["base"].Extract.Module != "extract" || lanes["base"].Extract.Retries != 2 { + t.Fatalf("base lane = %#v", lanes["base"]) + } + if lanes["derived"].Extract.Module != "extract" || lanes["derived"].Extract.Retries != 4 { + t.Fatalf("derived lane = %#v", lanes["derived"]) + } + if _, err := loaded.Config.Resolve(ResolveInput{PipelineID: "session", Catalog: effectiveCatalog(t)}); err != nil { + t.Fatalf("Resolve() error = %v", err) + } +}