diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f2a10786..fc907a70 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -296,6 +296,8 @@ absolute reference fixtures until Stage 7 supplies relative path resolution. ## Stage 5 — Support ConfigKit Dynamic Integers In Modules +**Status:** Completed 2026-09-13. + **Goal:** Ensure the module boundary accepts valid ConfigKit options. **Work:** diff --git a/internal/core/config/loaded_config_test.go b/internal/core/config/loaded_config_test.go index 67b74d1c..96458fc7 100644 --- a/internal/core/config/loaded_config_test.go +++ b/internal/core/config/loaded_config_test.go @@ -7,6 +7,8 @@ import ( configkit "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" + units "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/chunk/units" + jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/output/json" ) func TestLoadedConfigConvertsDocumentsAndResolvesRuntimePipeline(t *testing.T) { @@ -91,6 +93,40 @@ pipelines: } } +func TestLoadedConfigPreservesConfigKitIntegerOptionsForModules(t *testing.T) { + t.Parallel() + + loaded, err := loadConfigSource(configkit.Bytes("document.yml", []byte(`version: 5 +pipelines: + session: + input: {module: input} + chunk: + module: generic + options: + max_units: 3 + overlap_units: 1 + output: + module: json + options: + evidence_context: + enabled: true + lanes: [score] + window_units: 2 +`)), LoadOptions{}) + if err != nil { + t.Fatalf("loadConfigSource() error = %v", err) + } + profile := loaded.Config.Pipelines["session"] + chunkOptions, err := units.DecodeOptions(profile.Chunk.Options) + if err != nil || chunkOptions.MaxUnits != 3 || chunkOptions.OverlapUnits != 1 { + t.Fatalf("chunk options = %#v, %v", chunkOptions, err) + } + outputOptions, err := jsonoutput.DecodeOptions(profile.Output.Options) + if err != nil || !outputOptions.EvidenceContext.Enabled || outputOptions.EvidenceContext.WindowUnits != 2 { + t.Fatalf("output options = %#v, %v", outputOptions, err) + } +} + func pipelineProfileWithChangedOption(profile pipeline.PipelineProfile) pipeline.PipelineProfile { profile = clonePipelineProfile(profile) nested := profile.Artifacts["score"].Extract.Options["nested"].(map[string]any) diff --git a/internal/framework/pipeline/construction.go b/internal/framework/pipeline/construction.go index c4e3b9a5..66aee8f3 100644 --- a/internal/framework/pipeline/construction.go +++ b/internal/framework/pipeline/construction.go @@ -69,3 +69,22 @@ func RejectUnknownOptions(options map[string]any, allowed ...string) error { } return fmt.Errorf("unknown options %q", unknown) } + +// IntOption accepts the two integer representations admitted at the module +// boundary: native int values from programmatic bindings and int64 values from +// ConfigKit dynamic option maps. Callers remain responsible for their own +// domain constraints. +func IntOption(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case int64: + converted := int(typed) + if int64(converted) != typed { + return 0, false + } + return converted, true + default: + return 0, false + } +} diff --git a/internal/modules/generic/chunk/units/chunker.go b/internal/modules/generic/chunk/units/chunker.go index 1c59080c..b51c759d 100644 --- a/internal/modules/generic/chunk/units/chunker.go +++ b/internal/modules/generic/chunk/units/chunker.go @@ -155,7 +155,7 @@ func nonNegativeIntOption(name string, value any) (int, error) { } func intOption(name string, value any) (int, error) { - got, ok := value.(int) + got, ok := pipeline.IntOption(value) if !ok { return 0, chunkerErrorf("%s must be an integer", name) } diff --git a/internal/modules/generic/chunk/units/chunker_test.go b/internal/modules/generic/chunk/units/chunker_test.go index 8e1aaf19..88ff2987 100644 --- a/internal/modules/generic/chunk/units/chunker_test.go +++ b/internal/modules/generic/chunk/units/chunker_test.go @@ -121,9 +121,19 @@ func TestPlanRejectsInvalidOptions(t *testing.T) { } } -func TestDecodeOptionsRejectsNonNativeIntegers(t *testing.T) { +func TestDecodeOptionsAcceptsConfigKitIntegers(t *testing.T) { + options, err := DecodeOptions(map[string]any{"max_units": int64(2), "overlap_units": int64(1)}) + if err != nil { + t.Fatalf("DecodeOptions() error = %v", err) + } + if options.MaxUnits != 2 || options.OverlapUnits != 1 { + t.Fatalf("DecodeOptions() = %#v", options) + } +} + +func TestDecodeOptionsRejectsUnsupportedIntegerRepresentations(t *testing.T) { for _, value := range []any{ - int8(2), int16(2), int32(2), int64(2), + int8(2), int16(2), int32(2), uint(2), uint8(2), uint16(2), uint32(2), uint64(2), float32(2), float64(2), json.Number("2"), } { diff --git a/internal/modules/generic/output/json/encoder.go b/internal/modules/generic/output/json/encoder.go index 20ff9079..02d26e6b 100644 --- a/internal/modules/generic/output/json/encoder.go +++ b/internal/modules/generic/output/json/encoder.go @@ -182,7 +182,7 @@ func decodeEvidenceContextPolicy(value any) (pipeline.EvidenceContextPolicy, err } windowUnits := 3 if rawWindow, ok := object["window_units"]; ok { - value, ok := rawWindow.(int) + value, ok := pipeline.IntOption(rawWindow) if !ok { return pipeline.EvidenceContextPolicy{}, encoderErrorf("evidence_context option %q must be an integer", "window_units") } diff --git a/internal/modules/generic/output/json/encoder_test.go b/internal/modules/generic/output/json/encoder_test.go index e4823c86..a4f9615a 100644 --- a/internal/modules/generic/output/json/encoder_test.go +++ b/internal/modules/generic/output/json/encoder_test.go @@ -87,6 +87,7 @@ func TestDecodeEvidenceContextOptions(t *testing.T) { {name: "disabled", options: map[string]any{"evidence_context": map[string]any{"enabled": false}}}, {name: "enabled default window", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}}}, want: pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: 3, LaneIDs: []string{"npcs"}}}, {name: "explicit zero window and normalized lanes", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{" spells ", "npcs"}, "window_units": 0}}, want: pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: 0, LaneIDs: []string{"npcs", "spells"}}}, + {name: "ConfigKit window integer", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs"}, "window_units": int64(2)}}, want: pipeline.EvidenceContextPolicy{Enabled: true, WindowUnits: 2, LaneIDs: []string{"npcs"}}}, {name: "duplicate lanes", options: map[string]any{"evidence_context": map[string]any{"enabled": true, "lanes": []any{"npcs", " npcs "}}}, wantErr: "duplicated"}, {name: "unknown nested option", options: map[string]any{"evidence_context": map[string]any{"enabled": false, "extra": true}}, wantErr: "unknown option"}, {name: "disabled nested fields", options: map[string]any{"evidence_context": map[string]any{"enabled": false, "lanes": []any{"npcs"}}}, wantErr: "not allowed"},