dspy 0.25.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 55addf122534bacff753f272a385ddb035e66322ed63ecc5bc27ce3a2bd4ea03
4
- data.tar.gz: b73d12f9f560dcaf60fdfac9640bf6a422bb0b912912bf805dfb183058e94f81
3
+ metadata.gz: e243b7278275462baea2f493270166a1ae4b5419d4f072a769e4ba4b0f65e3e0
4
+ data.tar.gz: 13bcbcf4ee67c08f19ad619bc8118e39ca9a02045c5a18b3a664fb112724fa87
5
5
  SHA512:
6
- metadata.gz: 9ffff85304ccbf2b72143e878e134637b97db118ac7b69e91597664d24e1a4fad63685219844d75c91c5e859dc7080a39052ac5baa68df520f1fb6731f35aece
7
- data.tar.gz: f845270b9fbe9ff81fbed8519517cf174add60eb629d26702d8411077347a4dd1b9206bbf6b1fd7b4b4b7be260b35f48e2dfaf3951ae9d0e87b6d1aad7ea6b3e
6
+ metadata.gz: 687385021bf9391b22ae51a3f7c05880bec9691347a4e8ecac9175b7e81190c9f63cb0670a94e7324a045d748346cc91b6f7e174808607eaf0d02b8a0a117992
7
+ data.tar.gz: 3212712d53aca34cbc475503396d4fbeb7b8c11632b673782e7cd2dc2e6fdb22a3ad67688d92b60bd72e845f7b598b446e94fd3f5c6efd5e87f212bb1be14b9e
data/README.md CHANGED
@@ -73,7 +73,7 @@ puts result.confidence # => 0.85
73
73
  - **Prompt Objects** - Manipulate prompts as first-class objects instead of strings
74
74
  - **Typed Examples** - Type-safe training data with automatic validation
75
75
  - **Evaluation Framework** - Advanced metrics beyond simple accuracy with error-resilient pipelines
76
- - **MIPROv2 Optimization** - Automatic prompt optimization with storage and persistence
76
+ - **MIPROv2 Optimization** - Advanced Bayesian optimization with Gaussian Processes, multiple optimization strategies, and storage persistence
77
77
  - **GEPA Optimization** - Genetic-Pareto optimization for multi-objective prompt improvement
78
78
 
79
79
  **Production Features:**
@@ -128,7 +128,7 @@ For LLMs and AI assistants working with DSPy.rb:
128
128
  ### Optimization
129
129
  - **[Evaluation Framework](docs/src/optimization/evaluation.md)** - Advanced metrics beyond simple accuracy
130
130
  - **[Prompt Optimization](docs/src/optimization/prompt-optimization.md)** - Manipulate prompts as objects
131
- - **[MIPROv2 Optimizer](docs/src/optimization/miprov2.md)** - Automatic optimization algorithms
131
+ - **[MIPROv2 Optimizer](docs/src/optimization/miprov2.md)** - Advanced Bayesian optimization with Gaussian Processes
132
132
  - **[GEPA Optimizer](docs/src/optimization/gepa.md)** - Genetic-Pareto optimization for multi-objective prompt optimization
133
133
 
134
134
  ### Production Features
@@ -157,30 +157,6 @@ Then run:
157
157
  bundle install
158
158
  ```
159
159
 
160
- #### System Dependencies for Ubuntu/Pop!_OS
161
-
162
- If you need to compile the `polars-df` dependency from source (used for data processing in evaluations), install these system packages:
163
-
164
- ```bash
165
- # Update package list
166
- sudo apt-get update
167
-
168
- # Install Ruby development files (if not already installed)
169
- sudo apt-get install ruby-full ruby-dev
170
-
171
- # Install essential build tools
172
- sudo apt-get install build-essential
173
-
174
- # Install Rust and Cargo (required for polars-df compilation)
175
- curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
176
- source $HOME/.cargo/env
177
-
178
- # Install CMake (often needed for Rust projects)
179
- sudo apt-get install cmake
180
- ```
181
-
182
- **Note**: The `polars-df` gem compilation can take 15-20 minutes. Pre-built binaries are available for most platforms, so compilation is only needed if a pre-built binary isn't available for your system.
183
-
184
160
  ## Recent Achievements
185
161
 
186
162
  DSPy.rb has rapidly evolved from experimental to production-ready:
@@ -190,7 +166,7 @@ DSPy.rb has rapidly evolved from experimental to production-ready:
190
166
  - ✅ **Type-Safe Strategy Configuration** - Provider-optimized automatic strategy selection
191
167
  - ✅ **Core Module System** - Predict, ChainOfThought, ReAct, CodeAct with type safety
192
168
  - ✅ **Production Observability** - OpenTelemetry, New Relic, and Langfuse integration
193
- - ✅ **Optimization Framework** - MIPROv2 algorithm with storage & persistence
169
+ - ✅ **Advanced Optimization** - MIPROv2 with Bayesian optimization, Gaussian Processes, and multiple strategies
194
170
 
195
171
  ### Recent Advances
196
172
  - ✅ **Enhanced Langfuse Integration (v0.25.0)** - Comprehensive OpenTelemetry span reporting with proper input/output, hierarchical nesting, accurate timing, and observation types
@@ -0,0 +1,141 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'numo/narray'
5
+ require 'sorbet-runtime'
6
+
7
+ module DSPy
8
+ module Optimizers
9
+ # Pure Ruby Gaussian Process implementation for Bayesian optimization
10
+ # No external LAPACK/BLAS dependencies required
11
+ class GaussianProcess
12
+ extend T::Sig
13
+
14
+ sig { params(length_scale: Float, signal_variance: Float, noise_variance: Float).void }
15
+ def initialize(length_scale: 1.0, signal_variance: 1.0, noise_variance: 1e-6)
16
+ @length_scale = length_scale
17
+ @signal_variance = signal_variance
18
+ @noise_variance = noise_variance
19
+ @fitted = T.let(false, T::Boolean)
20
+ end
21
+
22
+ sig { params(x1: T::Array[T::Array[Float]], x2: T::Array[T::Array[Float]]).returns(Numo::DFloat) }
23
+ def rbf_kernel(x1, x2)
24
+ # Convert to Numo arrays
25
+ x1_array = Numo::DFloat[*x1]
26
+ x2_array = Numo::DFloat[*x2]
27
+
28
+ # Compute squared Euclidean distances manually
29
+ n1, n2 = x1_array.shape[0], x2_array.shape[0]
30
+ sqdist = Numo::DFloat.zeros(n1, n2)
31
+
32
+ (0...n1).each do |i|
33
+ (0...n2).each do |j|
34
+ diff = x1_array[i, true] - x2_array[j, true]
35
+ sqdist[i, j] = (diff ** 2).sum
36
+ end
37
+ end
38
+
39
+ # RBF kernel: σ² * exp(-0.5 * d² / ℓ²)
40
+ @signal_variance * Numo::NMath.exp(-0.5 * sqdist / (@length_scale ** 2))
41
+ end
42
+
43
+ sig { params(x_train: T::Array[T::Array[Float]], y_train: T::Array[Float]).void }
44
+ def fit(x_train, y_train)
45
+ @x_train = x_train
46
+ @y_train = Numo::DFloat[*y_train]
47
+
48
+ # Compute kernel matrix
49
+ k_matrix = rbf_kernel(x_train, x_train)
50
+
51
+ # Add noise to diagonal for numerical stability
52
+ n = k_matrix.shape[0]
53
+ (0...n).each { |i| k_matrix[i, i] += @noise_variance }
54
+
55
+ # Store inverted kernel matrix using simple LU decomposition
56
+ @k_inv = matrix_inverse(k_matrix)
57
+ @alpha = @k_inv.dot(@y_train)
58
+
59
+ @fitted = true
60
+ end
61
+
62
+ sig { params(x_test: T::Array[T::Array[Float]], return_std: T::Boolean).returns(T.any(Numo::DFloat, [Numo::DFloat, Numo::DFloat])) }
63
+ def predict(x_test, return_std: false)
64
+ raise "Gaussian Process not fitted" unless @fitted
65
+
66
+ # Kernel between training and test points
67
+ k_star = rbf_kernel(T.must(@x_train), x_test)
68
+
69
+ # Predictive mean
70
+ mean = k_star.transpose.dot(@alpha)
71
+
72
+ return mean unless return_std
73
+
74
+ # Predictive variance (simplified for small matrices)
75
+ k_star_star = rbf_kernel(x_test, x_test)
76
+ var_matrix = k_star_star - k_star.transpose.dot(@k_inv).dot(k_star)
77
+ var = var_matrix.diagonal
78
+
79
+ # Ensure positive variance (element-wise maximum)
80
+ var = var.map { |v| [v, 1e-12].max }
81
+ std = Numo::NMath.sqrt(var)
82
+
83
+ [mean, std]
84
+ end
85
+
86
+ private
87
+
88
+ sig { returns(T.nilable(T::Array[T::Array[Float]])) }
89
+ attr_reader :x_train
90
+
91
+ sig { returns(T.nilable(Numo::DFloat)) }
92
+ attr_reader :y_train, :k_inv, :alpha
93
+
94
+ # Simple matrix inversion using Gauss-Jordan elimination
95
+ # Only suitable for small matrices (< 100x100)
96
+ sig { params(matrix: Numo::DFloat).returns(Numo::DFloat) }
97
+ def matrix_inverse(matrix)
98
+ n = matrix.shape[0]
99
+ raise "Matrix must be square" unless matrix.shape[0] == matrix.shape[1]
100
+
101
+ # Create augmented matrix [A|I]
102
+ augmented = Numo::DFloat.zeros(n, 2*n)
103
+ augmented[true, 0...n] = matrix.copy
104
+ (0...n).each { |i| augmented[i, n+i] = 1.0 }
105
+
106
+ # Gauss-Jordan elimination
107
+ (0...n).each do |i|
108
+ # Find pivot
109
+ max_row = i
110
+ (i+1...n).each do |k|
111
+ if augmented[k, i].abs > augmented[max_row, i].abs
112
+ max_row = k
113
+ end
114
+ end
115
+
116
+ # Swap rows if needed
117
+ if max_row != i
118
+ temp = augmented[i, true].copy
119
+ augmented[i, true] = augmented[max_row, true]
120
+ augmented[max_row, true] = temp
121
+ end
122
+
123
+ # Make diagonal element 1
124
+ pivot = augmented[i, i]
125
+ raise "Matrix is singular" if pivot.abs < 1e-12
126
+ augmented[i, true] /= pivot
127
+
128
+ # Eliminate column
129
+ (0...n).each do |j|
130
+ next if i == j
131
+ factor = augmented[j, i]
132
+ augmented[j, true] -= factor * augmented[i, true]
133
+ end
134
+ end
135
+
136
+ # Extract inverse matrix
137
+ augmented[true, n...2*n]
138
+ end
139
+ end
140
+ end
141
+ end
@@ -5,14 +5,34 @@ require 'sorbet-runtime'
5
5
  require_relative 'teleprompter'
6
6
  require_relative 'utils'
7
7
  require_relative '../propose/grounded_proposer'
8
+ require_relative '../optimizers/gaussian_process'
8
9
 
9
10
  module DSPy
10
11
  module Teleprompt
12
+ # Enum for candidate configuration types
13
+ class CandidateType < T::Enum
14
+ enums do
15
+ Baseline = new("baseline")
16
+ InstructionOnly = new("instruction_only")
17
+ FewShotOnly = new("few_shot_only")
18
+ Combined = new("combined")
19
+ end
20
+ end
21
+
22
+ # Enum for optimization strategies
23
+ class OptimizationStrategy < T::Enum
24
+ enums do
25
+ Greedy = new("greedy")
26
+ Adaptive = new("adaptive")
27
+ Bayesian = new("bayesian")
28
+ end
29
+ end
11
30
  # MIPROv2: Multi-prompt Instruction Proposal with Retrieval Optimization
12
31
  # State-of-the-art prompt optimization combining bootstrap sampling,
13
32
  # instruction generation, and Bayesian optimization
14
33
  class MIPROv2 < Teleprompter
15
34
  extend T::Sig
35
+ include Dry::Configurable
16
36
 
17
37
  # Auto-configuration modes for different optimization needs
18
38
  module AutoMode
@@ -25,15 +45,17 @@ module DSPy
25
45
  ).returns(MIPROv2)
26
46
  end
27
47
  def self.light(metric: nil, **kwargs)
28
- config = MIPROv2Config.new
29
- config.num_trials = 6
30
- config.num_instruction_candidates = 3
31
- config.max_bootstrapped_examples = 2
32
- config.max_labeled_examples = 8
33
- config.bootstrap_sets = 3
34
- config.optimization_strategy = "greedy"
35
- config.early_stopping_patience = 2
36
- MIPROv2.new(metric: metric, config: config, **kwargs)
48
+ optimizer = MIPROv2.new(metric: metric, **kwargs)
49
+ optimizer.configure do |config|
50
+ config.num_trials = 6
51
+ config.num_instruction_candidates = 3
52
+ config.max_bootstrapped_examples = 2
53
+ config.max_labeled_examples = 8
54
+ config.bootstrap_sets = 3
55
+ config.optimization_strategy = :greedy
56
+ config.early_stopping_patience = 2
57
+ end
58
+ optimizer
37
59
  end
38
60
 
39
61
  sig do
@@ -43,15 +65,17 @@ module DSPy
43
65
  ).returns(MIPROv2)
44
66
  end
45
67
  def self.medium(metric: nil, **kwargs)
46
- config = MIPROv2Config.new
47
- config.num_trials = 12
48
- config.num_instruction_candidates = 5
49
- config.max_bootstrapped_examples = 4
50
- config.max_labeled_examples = 16
51
- config.bootstrap_sets = 5
52
- config.optimization_strategy = "adaptive"
53
- config.early_stopping_patience = 3
54
- MIPROv2.new(metric: metric, config: config, **kwargs)
68
+ optimizer = MIPROv2.new(metric: metric, **kwargs)
69
+ optimizer.configure do |config|
70
+ config.num_trials = 12
71
+ config.num_instruction_candidates = 5
72
+ config.max_bootstrapped_examples = 4
73
+ config.max_labeled_examples = 16
74
+ config.bootstrap_sets = 5
75
+ config.optimization_strategy = :adaptive
76
+ config.early_stopping_patience = 3
77
+ end
78
+ optimizer
55
79
  end
56
80
 
57
81
  sig do
@@ -61,137 +85,102 @@ module DSPy
61
85
  ).returns(MIPROv2)
62
86
  end
63
87
  def self.heavy(metric: nil, **kwargs)
64
- config = MIPROv2Config.new
65
- config.num_trials = 18
66
- config.num_instruction_candidates = 8
67
- config.max_bootstrapped_examples = 6
68
- config.max_labeled_examples = 24
69
- config.bootstrap_sets = 8
70
- config.optimization_strategy = "bayesian"
71
- config.early_stopping_patience = 5
72
- MIPROv2.new(metric: metric, config: config, **kwargs)
88
+ optimizer = MIPROv2.new(metric: metric, **kwargs)
89
+ optimizer.configure do |config|
90
+ config.num_trials = 18
91
+ config.num_instruction_candidates = 8
92
+ config.max_bootstrapped_examples = 6
93
+ config.max_labeled_examples = 24
94
+ config.bootstrap_sets = 8
95
+ config.optimization_strategy = :bayesian
96
+ config.early_stopping_patience = 5
97
+ end
98
+ optimizer
73
99
  end
74
100
  end
75
101
 
76
- # Configuration for MIPROv2 optimization
77
- class MIPROv2Config < Config
78
- extend T::Sig
79
-
80
- sig { returns(Integer) }
81
- attr_accessor :num_trials
82
-
83
- sig { returns(Integer) }
84
- attr_accessor :num_instruction_candidates
85
-
86
- sig { returns(Integer) }
87
- attr_accessor :bootstrap_sets
88
-
89
- sig { returns(String) }
90
- attr_accessor :optimization_strategy
91
-
92
- sig { returns(Float) }
93
- attr_accessor :init_temperature
94
-
95
- sig { returns(Float) }
96
- attr_accessor :final_temperature
97
-
98
- sig { returns(Integer) }
99
- attr_accessor :early_stopping_patience
100
-
101
- sig { returns(T::Boolean) }
102
- attr_accessor :use_bayesian_optimization
103
-
104
- sig { returns(T::Boolean) }
105
- attr_accessor :track_diversity
106
-
107
- sig { returns(DSPy::Propose::GroundedProposer::Config) }
108
- attr_accessor :proposer_config
109
-
110
- sig { void }
111
- def initialize
112
- super
113
- @num_trials = 12
114
- @num_instruction_candidates = 5
115
- @bootstrap_sets = 5
116
- @optimization_strategy = "adaptive" # greedy, adaptive, bayesian
117
- @init_temperature = 1.0
118
- @final_temperature = 0.1
119
- @early_stopping_patience = 3
120
- @use_bayesian_optimization = true
121
- @track_diversity = true
122
- @proposer_config = DSPy::Propose::GroundedProposer::Config.new
102
+ # Dry-configurable settings for MIPROv2
103
+ setting :num_trials, default: 12
104
+ setting :num_instruction_candidates, default: 5
105
+ setting :bootstrap_sets, default: 5
106
+ setting :max_bootstrapped_examples, default: 4
107
+ setting :max_labeled_examples, default: 16
108
+ setting :optimization_strategy, default: OptimizationStrategy::Adaptive, constructor: ->(value) {
109
+ # Coerce symbols to enum values
110
+ case value
111
+ when :greedy then OptimizationStrategy::Greedy
112
+ when :adaptive then OptimizationStrategy::Adaptive
113
+ when :bayesian then OptimizationStrategy::Bayesian
114
+ when OptimizationStrategy then value
115
+ when nil then OptimizationStrategy::Adaptive
116
+ else
117
+ raise ArgumentError, "Invalid optimization strategy: #{value}. Must be one of :greedy, :adaptive, :bayesian"
123
118
  end
124
-
125
- sig { returns(T::Hash[Symbol, T.untyped]) }
126
- def to_h
127
- super.merge({
128
- num_trials: @num_trials,
129
- num_instruction_candidates: @num_instruction_candidates,
130
- bootstrap_sets: @bootstrap_sets,
131
- optimization_strategy: @optimization_strategy,
132
- init_temperature: @init_temperature,
133
- final_temperature: @final_temperature,
134
- early_stopping_patience: @early_stopping_patience,
135
- use_bayesian_optimization: @use_bayesian_optimization,
136
- track_diversity: @track_diversity
137
- })
119
+ }
120
+ setting :init_temperature, default: 1.0
121
+ setting :final_temperature, default: 0.1
122
+ setting :early_stopping_patience, default: 3
123
+ setting :use_bayesian_optimization, default: true
124
+ setting :track_diversity, default: true
125
+ setting :max_errors, default: 3
126
+ setting :num_threads, default: 1
127
+
128
+ # Class-level configuration method - sets defaults for new instances
129
+ def self.configure(&block)
130
+ if block_given?
131
+ # Store configuration in a class variable for new instances
132
+ @default_config_block = block
138
133
  end
139
134
  end
140
135
 
141
- # Candidate configuration for optimization trials
142
- class CandidateConfig
143
- extend T::Sig
144
-
145
- sig { returns(String) }
146
- attr_reader :instruction
147
-
148
- sig { returns(T::Array[T.untyped]) }
149
- attr_reader :few_shot_examples
150
-
151
- sig { returns(T::Hash[Symbol, T.untyped]) }
152
- attr_reader :metadata
136
+ # Get the default configuration block
137
+ def self.default_config_block
138
+ @default_config_block
139
+ end
153
140
 
154
- sig { returns(String) }
155
- attr_reader :config_id
156
141
 
157
- sig do
158
- params(
159
- instruction: String,
160
- few_shot_examples: T::Array[T.untyped],
161
- metadata: T::Hash[Symbol, T.untyped]
162
- ).void
163
- end
164
- def initialize(instruction:, few_shot_examples:, metadata: {})
165
- @instruction = instruction
166
- @few_shot_examples = few_shot_examples
167
- @metadata = metadata.freeze
168
- @config_id = generate_config_id
142
+ # Simple data structure for evaluated candidate configurations (immutable)
143
+ EvaluatedCandidate = Data.define(
144
+ :instruction,
145
+ :few_shot_examples,
146
+ :type,
147
+ :metadata,
148
+ :config_id
149
+ ) do
150
+ extend T::Sig
151
+
152
+ # Generate a config ID based on content
153
+ sig { params(instruction: String, few_shot_examples: T::Array[T.untyped], type: CandidateType, metadata: T::Hash[Symbol, T.untyped]).returns(EvaluatedCandidate) }
154
+ def self.create(instruction:, few_shot_examples: [], type: CandidateType::Baseline, metadata: {})
155
+ content = "#{instruction}_#{few_shot_examples.size}_#{type.serialize}_#{metadata.hash}"
156
+ config_id = Digest::SHA256.hexdigest(content)[0, 12]
157
+
158
+ new(
159
+ instruction: instruction.freeze,
160
+ few_shot_examples: few_shot_examples.freeze,
161
+ type: type,
162
+ metadata: metadata.freeze,
163
+ config_id: config_id
164
+ )
169
165
  end
170
166
 
171
167
  sig { returns(T::Hash[Symbol, T.untyped]) }
172
168
  def to_h
173
169
  {
174
- instruction: @instruction,
175
- few_shot_examples: @few_shot_examples.size,
176
- metadata: @metadata,
177
- config_id: @config_id
170
+ instruction: instruction,
171
+ few_shot_examples: few_shot_examples.size,
172
+ type: type.serialize,
173
+ metadata: metadata,
174
+ config_id: config_id
178
175
  }
179
176
  end
180
-
181
- private
182
-
183
- sig { returns(String) }
184
- def generate_config_id
185
- content = "#{@instruction}_#{@few_shot_examples.size}_#{@metadata.hash}"
186
- Digest::SHA256.hexdigest(content)[0, 12]
187
- end
188
177
  end
189
178
 
190
179
  # Result of MIPROv2 optimization
191
180
  class MIPROv2Result < OptimizationResult
192
181
  extend T::Sig
193
182
 
194
- sig { returns(T::Array[CandidateConfig]) }
183
+ sig { returns(T::Array[EvaluatedCandidate]) }
195
184
  attr_reader :evaluated_candidates
196
185
 
197
186
  sig { returns(T::Hash[Symbol, T.untyped]) }
@@ -211,7 +200,7 @@ module DSPy
211
200
  optimized_program: T.untyped,
212
201
  scores: T::Hash[Symbol, T.untyped],
213
202
  history: T::Hash[Symbol, T.untyped],
214
- evaluated_candidates: T::Array[CandidateConfig],
203
+ evaluated_candidates: T::Array[EvaluatedCandidate],
215
204
  optimization_trace: T::Hash[Symbol, T.untyped],
216
205
  bootstrap_statistics: T::Hash[Symbol, T.untyped],
217
206
  proposal_statistics: T::Hash[Symbol, T.untyped],
@@ -255,17 +244,25 @@ module DSPy
255
244
  sig { returns(T.nilable(DSPy::Propose::GroundedProposer)) }
256
245
  attr_reader :proposer
257
246
 
258
- sig do
259
- params(
260
- metric: T.nilable(T.proc.params(arg0: T.untyped, arg1: T.untyped).returns(T.untyped)),
261
- config: T.nilable(MIPROv2Config)
262
- ).void
263
- end
264
- def initialize(metric: nil, config: nil)
265
- @mipro_config = config || MIPROv2Config.new
266
- super(metric: metric, config: @mipro_config)
247
+ # Override dry-configurable's initialize to add our parameter validation
248
+ def initialize(metric: nil, **kwargs)
249
+ # Reject old config parameter pattern
250
+ if kwargs.key?(:config)
251
+ raise ArgumentError, "config parameter is no longer supported. Use .configure blocks instead."
252
+ end
267
253
 
268
- @proposer = DSPy::Propose::GroundedProposer.new(config: @mipro_config.proposer_config)
254
+ # Let dry-configurable handle its initialization
255
+ super(**kwargs)
256
+
257
+ # Apply class-level configuration if it exists
258
+ if self.class.default_config_block
259
+ configure(&self.class.default_config_block)
260
+ end
261
+
262
+ @metric = metric
263
+
264
+ # Initialize proposer with a basic config for now (will be updated later)
265
+ @proposer = DSPy::Propose::GroundedProposer.new(config: DSPy::Propose::GroundedProposer::Config.new)
269
266
  @optimization_trace = []
270
267
  @evaluated_candidates = []
271
268
  end
@@ -284,8 +281,8 @@ module DSPy
284
281
  instrument_step('miprov2_compile', {
285
282
  trainset_size: trainset.size,
286
283
  valset_size: valset&.size || 0,
287
- num_trials: @mipro_config.num_trials,
288
- optimization_strategy: @mipro_config.optimization_strategy,
284
+ num_trials: config.num_trials,
285
+ optimization_strategy: config.optimization_strategy,
289
286
  mode: infer_auto_mode
290
287
  }) do
291
288
  # Convert examples to typed format
@@ -345,11 +342,11 @@ module DSPy
345
342
  sig { params(program: T.untyped, trainset: T::Array[DSPy::Example]).returns(Utils::BootstrapResult) }
346
343
  def phase_1_bootstrap(program, trainset)
347
344
  bootstrap_config = Utils::BootstrapConfig.new
348
- bootstrap_config.max_bootstrapped_examples = @mipro_config.max_bootstrapped_examples
349
- bootstrap_config.max_labeled_examples = @mipro_config.max_labeled_examples
350
- bootstrap_config.num_candidate_sets = @mipro_config.bootstrap_sets
351
- bootstrap_config.max_errors = @mipro_config.max_errors
352
- bootstrap_config.num_threads = @mipro_config.num_threads
345
+ bootstrap_config.max_bootstrapped_examples = config.max_bootstrapped_examples
346
+ bootstrap_config.max_labeled_examples = config.max_labeled_examples
347
+ bootstrap_config.num_candidate_sets = config.bootstrap_sets
348
+ bootstrap_config.max_errors = config.max_errors
349
+ bootstrap_config.num_threads = config.num_threads
353
350
 
354
351
  Utils.create_n_fewshot_demo_sets(program, trainset, config: bootstrap_config, metric: @metric)
355
352
  end
@@ -374,7 +371,7 @@ module DSPy
374
371
  raise ArgumentError, "Cannot extract signature class from program" unless signature_class
375
372
 
376
373
  # Configure proposer for this optimization run
377
- @mipro_config.proposer_config.num_instruction_candidates = @mipro_config.num_instruction_candidates
374
+ @proposer.config.num_instruction_candidates = config.num_instruction_candidates
378
375
 
379
376
  @proposer.propose_instructions(
380
377
  signature_class,
@@ -407,7 +404,7 @@ module DSPy
407
404
  best_program = nil
408
405
  best_evaluation_result = nil
409
406
 
410
- @mipro_config.num_trials.times do |trial_idx|
407
+ config.num_trials.times do |trial_idx|
411
408
  trials_completed = trial_idx + 1
412
409
 
413
410
  # Select next candidate based on optimization strategy
@@ -476,33 +473,39 @@ module DSPy
476
473
  params(
477
474
  proposal_result: DSPy::Propose::GroundedProposer::ProposalResult,
478
475
  bootstrap_result: Utils::BootstrapResult
479
- ).returns(T::Array[CandidateConfig])
476
+ ).returns(T::Array[EvaluatedCandidate])
480
477
  end
481
478
  def generate_candidate_configurations(proposal_result, bootstrap_result)
482
479
  candidates = []
483
480
 
484
481
  # Base configuration (no modifications)
485
- candidates << CandidateConfig.new(
482
+ candidates << EvaluatedCandidate.new(
486
483
  instruction: "",
487
484
  few_shot_examples: [],
488
- metadata: { type: "baseline" }
485
+ type: CandidateType::Baseline,
486
+ metadata: {},
487
+ config_id: SecureRandom.hex(6)
489
488
  )
490
489
 
491
490
  # Instruction-only candidates
492
491
  proposal_result.candidate_instructions.each_with_index do |instruction, idx|
493
- candidates << CandidateConfig.new(
492
+ candidates << EvaluatedCandidate.new(
494
493
  instruction: instruction,
495
494
  few_shot_examples: [],
496
- metadata: { type: "instruction_only", proposal_rank: idx }
495
+ type: CandidateType::InstructionOnly,
496
+ metadata: { proposal_rank: idx },
497
+ config_id: SecureRandom.hex(6)
497
498
  )
498
499
  end
499
500
 
500
501
  # Few-shot only candidates
501
502
  bootstrap_result.candidate_sets.each_with_index do |candidate_set, idx|
502
- candidates << CandidateConfig.new(
503
+ candidates << EvaluatedCandidate.new(
503
504
  instruction: "",
504
505
  few_shot_examples: candidate_set,
505
- metadata: { type: "few_shot_only", bootstrap_rank: idx }
506
+ type: CandidateType::FewShotOnly,
507
+ metadata: { bootstrap_rank: idx },
508
+ config_id: SecureRandom.hex(6)
506
509
  )
507
510
  end
508
511
 
@@ -512,14 +515,15 @@ module DSPy
512
515
 
513
516
  top_instructions.each_with_index do |instruction, i_idx|
514
517
  top_bootstrap_sets.each_with_index do |candidate_set, b_idx|
515
- candidates << CandidateConfig.new(
518
+ candidates << EvaluatedCandidate.new(
516
519
  instruction: instruction,
517
520
  few_shot_examples: candidate_set,
521
+ type: CandidateType::Combined,
518
522
  metadata: {
519
- type: "combined",
520
523
  instruction_rank: i_idx,
521
524
  bootstrap_rank: b_idx
522
- }
525
+ },
526
+ config_id: SecureRandom.hex(6)
523
527
  )
524
528
  end
525
529
  end
@@ -528,13 +532,13 @@ module DSPy
528
532
  end
529
533
 
530
534
  # Initialize optimization state for candidate selection
531
- sig { params(candidates: T::Array[CandidateConfig]).returns(T::Hash[Symbol, T.untyped]) }
535
+ sig { params(candidates: T::Array[EvaluatedCandidate]).returns(T::Hash[Symbol, T.untyped]) }
532
536
  def initialize_optimization_state(candidates)
533
537
  {
534
538
  candidates: candidates,
535
539
  scores: {},
536
540
  exploration_counts: Hash.new(0),
537
- temperature: @mipro_config.init_temperature,
541
+ temperature: config.init_temperature,
538
542
  best_score_history: [],
539
543
  diversity_scores: {},
540
544
  no_improvement_count: 0
@@ -544,18 +548,18 @@ module DSPy
544
548
  # Select next candidate based on optimization strategy
545
549
  sig do
546
550
  params(
547
- candidates: T::Array[CandidateConfig],
551
+ candidates: T::Array[EvaluatedCandidate],
548
552
  state: T::Hash[Symbol, T.untyped],
549
553
  trial_idx: Integer
550
- ).returns(CandidateConfig)
554
+ ).returns(EvaluatedCandidate)
551
555
  end
552
556
  def select_next_candidate(candidates, state, trial_idx)
553
- case @mipro_config.optimization_strategy
554
- when "greedy"
557
+ case config.optimization_strategy
558
+ when OptimizationStrategy::Greedy
555
559
  select_candidate_greedy(candidates, state)
556
- when "adaptive"
560
+ when OptimizationStrategy::Adaptive
557
561
  select_candidate_adaptive(candidates, state, trial_idx)
558
- when "bayesian"
562
+ when OptimizationStrategy::Bayesian
559
563
  select_candidate_bayesian(candidates, state, trial_idx)
560
564
  else
561
565
  candidates.sample # Random fallback
@@ -563,7 +567,7 @@ module DSPy
563
567
  end
564
568
 
565
569
  # Greedy candidate selection (exploit best known configurations)
566
- sig { params(candidates: T::Array[CandidateConfig], state: T::Hash[Symbol, T.untyped]).returns(CandidateConfig) }
570
+ sig { params(candidates: T::Array[EvaluatedCandidate], state: T::Hash[Symbol, T.untyped]).returns(EvaluatedCandidate) }
567
571
  def select_candidate_greedy(candidates, state)
568
572
  # Prioritize unexplored candidates, then highest scoring
569
573
  unexplored = candidates.reject { |c| state[:scores].key?(c.config_id) }
@@ -577,15 +581,15 @@ module DSPy
577
581
  # Adaptive candidate selection (balance exploration and exploitation)
578
582
  sig do
579
583
  params(
580
- candidates: T::Array[CandidateConfig],
584
+ candidates: T::Array[EvaluatedCandidate],
581
585
  state: T::Hash[Symbol, T.untyped],
582
586
  trial_idx: Integer
583
- ).returns(CandidateConfig)
587
+ ).returns(EvaluatedCandidate)
584
588
  end
585
589
  def select_candidate_adaptive(candidates, state, trial_idx)
586
590
  # Update temperature based on progress
587
- progress = trial_idx.to_f / @mipro_config.num_trials
588
- state[:temperature] = @mipro_config.init_temperature * (1 - progress) + @mipro_config.final_temperature * progress
591
+ progress = trial_idx.to_f / config.num_trials
592
+ state[:temperature] = config.init_temperature * (1 - progress) + config.final_temperature * progress
589
593
 
590
594
  # Calculate selection scores combining exploitation and exploration
591
595
  candidate_scores = candidates.map do |candidate|
@@ -618,22 +622,86 @@ module DSPy
618
622
  # Bayesian candidate selection (use probabilistic model)
619
623
  sig do
620
624
  params(
621
- candidates: T::Array[CandidateConfig],
625
+ candidates: T::Array[EvaluatedCandidate],
622
626
  state: T::Hash[Symbol, T.untyped],
623
627
  trial_idx: Integer
624
- ).returns(CandidateConfig)
628
+ ).returns(EvaluatedCandidate)
625
629
  end
626
630
  def select_candidate_bayesian(candidates, state, trial_idx)
627
- # For now, use adaptive selection with Bayesian-inspired exploration
628
- # In a full implementation, this would use Gaussian processes or similar
629
- select_candidate_adaptive(candidates, state, trial_idx)
631
+ # Need at least 3 observations to fit GP, otherwise fall back to adaptive
632
+ return select_candidate_adaptive(candidates, state, trial_idx) if state[:scores].size < 3
633
+
634
+ # Get scored candidates for training the GP
635
+ scored_candidates = candidates.select { |c| state[:scores].key?(c.config_id) }
636
+ return select_candidate_adaptive(candidates, state, trial_idx) if scored_candidates.size < 3
637
+
638
+ begin
639
+ # Encode candidates as numerical features
640
+ all_candidate_features = encode_candidates_for_gp(candidates)
641
+ scored_features = encode_candidates_for_gp(scored_candidates)
642
+ scored_targets = scored_candidates.map { |c| state[:scores][c.config_id].to_f }
643
+
644
+ # Train Gaussian Process
645
+ gp = DSPy::Optimizers::GaussianProcess.new(
646
+ length_scale: 1.0,
647
+ signal_variance: 1.0,
648
+ noise_variance: 0.01
649
+ )
650
+ gp.fit(scored_features, scored_targets)
651
+
652
+ # Predict mean and uncertainty for all candidates
653
+ means, stds = gp.predict(all_candidate_features, return_std: true)
654
+
655
+ # Upper Confidence Bound (UCB) acquisition function
656
+ kappa = 2.0 * Math.sqrt(Math.log(trial_idx + 1)) # Exploration parameter
657
+ acquisition_scores = means.to_a.zip(stds.to_a).map { |m, s| m + kappa * s }
658
+
659
+ # Select candidate with highest acquisition score
660
+ best_idx = acquisition_scores.each_with_index.max_by { |score, _| score }[1]
661
+ candidates[best_idx]
662
+
663
+ rescue => e
664
+ # If GP fails for any reason, fall back to adaptive selection
665
+ DSPy.logger.warn("Bayesian optimization failed: #{e.message}. Falling back to adaptive selection.")
666
+ select_candidate_adaptive(candidates, state, trial_idx)
667
+ end
668
+ end
669
+
670
+ private
671
+
672
+
673
+ # Encode candidates as numerical features for Gaussian Process
674
+ sig { params(candidates: T::Array[EvaluatedCandidate]).returns(T::Array[T::Array[Float]]) }
675
+ def encode_candidates_for_gp(candidates)
676
+ # Simple encoding: use hash of config as features
677
+ # In practice, this could be more sophisticated (e.g., instruction embeddings)
678
+ candidates.map do |candidate|
679
+ # Create deterministic numerical features from the candidate config
680
+ config_hash = candidate.config_id.hash.abs
681
+
682
+ # Extract multiple features to create a feature vector
683
+ features = []
684
+ features << (config_hash % 1000).to_f / 1000.0 # Feature 1: hash mod 1000, normalized
685
+ features << ((config_hash / 1000) % 1000).to_f / 1000.0 # Feature 2: different part of hash
686
+ features << ((config_hash / 1_000_000) % 1000).to_f / 1000.0 # Feature 3: high bits
687
+
688
+ # Add instruction length if available
689
+ instruction = candidate.instruction
690
+ if instruction && !instruction.empty?
691
+ features << [instruction.length.to_f / 100.0, 2.0].min # Instruction length, capped at 200 chars
692
+ else
693
+ features << 0.5 # Default value
694
+ end
695
+
696
+ features
697
+ end
630
698
  end
631
699
 
632
700
  # Evaluate a candidate configuration
633
701
  sig do
634
702
  params(
635
703
  program: T.untyped,
636
- candidate: CandidateConfig,
704
+ candidate: EvaluatedCandidate,
637
705
  evaluation_set: T::Array[DSPy::Example]
638
706
  ).returns([Float, T.untyped, DSPy::Evaluate::BatchEvaluationResult])
639
707
  end
@@ -651,7 +719,7 @@ module DSPy
651
719
  end
652
720
 
653
721
  # Apply candidate configuration to program
654
- sig { params(program: T.untyped, candidate: CandidateConfig).returns(T.untyped) }
722
+ sig { params(program: T.untyped, candidate: EvaluatedCandidate).returns(T.untyped) }
655
723
  def apply_candidate_configuration(program, candidate)
656
724
  modified_program = program
657
725
 
@@ -679,7 +747,7 @@ module DSPy
679
747
  sig do
680
748
  params(
681
749
  state: T::Hash[Symbol, T.untyped],
682
- candidate: CandidateConfig,
750
+ candidate: EvaluatedCandidate,
683
751
  score: Float
684
752
  ).void
685
753
  end
@@ -689,7 +757,7 @@ module DSPy
689
757
  state[:best_score_history] << score
690
758
 
691
759
  # Track diversity if enabled
692
- if @mipro_config.track_diversity
760
+ if config.track_diversity
693
761
  state[:diversity_scores][candidate.config_id] = calculate_diversity_score(candidate)
694
762
  end
695
763
 
@@ -705,14 +773,14 @@ module DSPy
705
773
  sig { params(state: T::Hash[Symbol, T.untyped], trial_idx: Integer).returns(T::Boolean) }
706
774
  def should_early_stop?(state, trial_idx)
707
775
  # Don't stop too early
708
- return false if trial_idx < @mipro_config.early_stopping_patience
776
+ return false if trial_idx < config.early_stopping_patience
709
777
 
710
778
  # Stop if no improvement for patience trials
711
- state[:no_improvement_count] >= @mipro_config.early_stopping_patience
779
+ state[:no_improvement_count] >= config.early_stopping_patience
712
780
  end
713
781
 
714
782
  # Calculate diversity score for candidate
715
- sig { params(candidate: CandidateConfig).returns(Float) }
783
+ sig { params(candidate: EvaluatedCandidate).returns(Float) }
716
784
  def calculate_diversity_score(candidate)
717
785
  # Simple diversity metric based on instruction length and few-shot count
718
786
  instruction_diversity = candidate.instruction.length / 200.0
@@ -739,8 +807,8 @@ module DSPy
739
807
 
740
808
  history = {
741
809
  total_trials: optimization_result[:trials_completed],
742
- optimization_strategy: @mipro_config.optimization_strategy,
743
- early_stopped: optimization_result[:trials_completed] < @mipro_config.num_trials,
810
+ optimization_strategy: config.optimization_strategy,
811
+ early_stopped: optimization_result[:trials_completed] < config.num_trials,
744
812
  score_history: optimization_result[:optimization_state][:best_score_history]
745
813
  }
746
814
 
@@ -749,7 +817,7 @@ module DSPy
749
817
  auto_mode: infer_auto_mode,
750
818
  best_instruction: best_candidate&.instruction || "",
751
819
  best_few_shot_count: best_candidate&.few_shot_examples&.size || 0,
752
- best_candidate_type: best_candidate&.metadata&.fetch(:type, "unknown"),
820
+ best_candidate_type: best_candidate&.type&.serialize || "unknown",
753
821
  optimization_timestamp: Time.now.iso8601
754
822
  }
755
823
 
@@ -820,7 +888,7 @@ module DSPy
820
888
  # Infer auto mode based on configuration
821
889
  sig { returns(String) }
822
890
  def infer_auto_mode
823
- case @mipro_config.num_trials
891
+ case config.num_trials
824
892
  when 0..6 then "light"
825
893
  when 7..12 then "medium"
826
894
  else "heavy"
data/lib/dspy/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DSPy
4
- VERSION = "0.25.1"
4
+ VERSION = "0.26.1"
5
5
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.1
4
+ version: 0.26.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-09-08 00:00:00.000000000 Z
10
+ date: 2025-09-10 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-configurable
@@ -122,19 +122,19 @@ dependencies:
122
122
  - !ruby/object:Gem::Version
123
123
  version: '0.3'
124
124
  - !ruby/object:Gem::Dependency
125
- name: polars-df
125
+ name: numo-narray
126
126
  requirement: !ruby/object:Gem::Requirement
127
127
  requirements:
128
128
  - - "~>"
129
129
  - !ruby/object:Gem::Version
130
- version: 0.20.0
130
+ version: '0.9'
131
131
  type: :runtime
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
135
  - - "~>"
136
136
  - !ruby/object:Gem::Version
137
- version: 0.20.0
137
+ version: '0.9'
138
138
  - !ruby/object:Gem::Dependency
139
139
  name: informers
140
140
  requirement: !ruby/object:Gem::Requirement
@@ -239,6 +239,7 @@ files:
239
239
  - lib/dspy/module.rb
240
240
  - lib/dspy/observability.rb
241
241
  - lib/dspy/observability/async_span_processor.rb
242
+ - lib/dspy/optimizers/gaussian_process.rb
242
243
  - lib/dspy/predict.rb
243
244
  - lib/dspy/prediction.rb
244
245
  - lib/dspy/prompt.rb