From 892cb74e6e1b3d139c1dfaaf413e51695e90f326 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 14 Nov 2026 14:29:39 +0000 Subject: [PATCH] Keep observer composition storage proportional to selected callbacks --- docs/roadmap/remediation.md | 8 +++++++- internal/observation/compose.go | 19 +++++++++++++++---- observer_composition_contract_test.go | 25 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 6747aaf..4b04c60 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -140,7 +140,13 @@ statically. Do not add public allocation guarantees or exact-allocation tests. ## 3. Size Observer Storage For Selected Callbacks -**Priority:** P3. **Status:** For consideration. **Plan stage:** 9. +**Priority:** P3. **Status:** Complete. **Plan stage:** 9. + +**Completion:** Observer composition counts selected callbacks before allocating, +sizes retained storage to that count, and handles zero/single selections without +a fan-out closure. Sparse-selection ownership and duplicate-delivery tests pass. +The complete offline race suite, maintained examples, vet, and diff checks passed +with Go 1.27.3 after all remediations. **Location:** [`Compose`](../../internal/observation/compose.go). diff --git a/internal/observation/compose.go b/internal/observation/compose.go index a58adb9..f4fc591 100644 --- a/internal/observation/compose.go +++ b/internal/observation/compose.go @@ -2,15 +2,26 @@ package observation // Compose copies and synchronously invokes non-nil observers in order. func Compose[T any, F ~func(T)](observers ...F) F { - selected := make([]F, 0, len(observers)) + count := 0 + var single F + for _, observer := range observers { + if observer != nil { + count++ + single = observer + } + } + if count == 0 { + return nil + } + if count == 1 { + return single + } + selected := make([]F, 0, count) for _, observer := range observers { if observer != nil { selected = append(selected, observer) } } - if len(selected) == 0 { - return nil - } return func(event T) { for _, observer := range selected { observer(event) diff --git a/observer_composition_contract_test.go b/observer_composition_contract_test.go index 73fb597..43f40ad 100644 --- a/observer_composition_contract_test.go +++ b/observer_composition_contract_test.go @@ -31,6 +31,31 @@ func TestComposeObserversCopiesAndOrders(t *testing.T) { promptkit.ComposeObservers(func(promptkit.ExecutionEvent) { panic("panic") }, second)(promptkit.ExecutionEvent{}) } +func TestComposeObserversSparseSelection(t *testing.T) { + for _, selected := range []int{0, 1, 2} { + calls := 0 + observers := make([]promptkit.ExecutionObserver, 1024) + callback := func(promptkit.ExecutionEvent) { calls++ } + for index := 0; index < selected; index++ { + observers[len(observers)-1-index] = callback + } + composed := promptkit.ComposeObservers(observers...) + for index := range observers { + observers[index] = func(promptkit.ExecutionEvent) { calls += 100 } + } + if selected == 0 { + if composed != nil { + t.Fatal("all-nil composition must be nil") + } + continue + } + composed(promptkit.ExecutionEvent{}) + if calls != selected { + t.Fatalf("selected %d callbacks, got %d calls", selected, calls) + } + } +} + func TestCoordinateObserverCompositionAndNestedSelection(t *testing.T) { engine := newSimpleEngine(t, "question", promptkit.WithLLMClient(&fakeLLMClient{response: &promptkit.GenerateResponse{Content: "answer"}})) ctx := context.Background()