dspy 0.28.0 → 0.28.2

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.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'sorbet-runtime'
4
+ require 'fileutils'
4
5
  require_relative '../evaluate'
5
6
  require_relative '../example'
6
7
  require_relative 'data_handler'
@@ -12,6 +13,167 @@ module DSPy
12
13
  module Utils
13
14
  extend T::Sig
14
15
 
16
+ # Wrapper class that provides Python-compatible signature API
17
+ # Wraps a Predict instance to provide signature access and modification
18
+ class SignatureWrapper
19
+ extend T::Sig
20
+
21
+ sig { returns(T.untyped) }
22
+ attr_reader :predictor
23
+
24
+ sig { params(predictor: T.untyped).void }
25
+ def initialize(predictor)
26
+ @predictor = predictor
27
+ end
28
+
29
+ sig { returns(String) }
30
+ def instructions
31
+ # Get instructions from the predictor's prompt
32
+ @predictor.prompt.instruction
33
+ end
34
+
35
+ sig { params(new_instructions: String).returns(SignatureWrapper) }
36
+ def with_instructions(new_instructions)
37
+ # Return a new wrapper that will apply new instructions when set
38
+ updated_wrapper = SignatureWrapper.new(@predictor)
39
+ updated_wrapper.instance_variable_set(:@pending_instructions, new_instructions)
40
+ updated_wrapper
41
+ end
42
+
43
+ sig { returns(T.nilable(String)) }
44
+ def pending_instructions
45
+ @pending_instructions
46
+ end
47
+ end
48
+
49
+ # Get signature information from a predictor (Python compatibility)
50
+ # Returns a wrapper that provides Python-like signature API
51
+ #
52
+ # @param predictor [Predict] The predictor to get signature from
53
+ # @return [SignatureWrapper] Wrapper providing signature access
54
+ sig { params(predictor: T.untyped).returns(SignatureWrapper) }
55
+ def self.get_signature(predictor)
56
+ SignatureWrapper.new(predictor)
57
+ end
58
+
59
+ # Set signature on a predictor (Python compatibility)
60
+ # Updates the predictor's prompt with new instructions
61
+ #
62
+ # @param predictor [Predict] The predictor to update
63
+ # @param updated_signature [SignatureWrapper] The updated signature wrapper
64
+ sig { params(predictor: T.untyped, updated_signature: SignatureWrapper).void }
65
+ def self.set_signature(predictor, updated_signature)
66
+ # Extract pending instructions from the wrapper
67
+ new_instructions = updated_signature.pending_instructions
68
+
69
+ if new_instructions
70
+ # Update the predictor's prompt with new instructions
71
+ # We mutate the prompt's instruction directly for MIPROv2 compatibility
72
+ predictor.prompt.instance_variable_set(:@instruction, new_instructions)
73
+ end
74
+ end
75
+
76
+ # Create a minibatch from the trainset using random sampling
77
+ # This function is compatible with Python DSPy's MIPROv2 implementation
78
+ #
79
+ # @param trainset [Array] The training dataset to sample from
80
+ # @param batch_size [Integer] The desired size of the minibatch (default: 50)
81
+ # @param rng [Random, nil] Optional random number generator for reproducible sampling
82
+ # @return [Array] A randomly sampled subset of the trainset
83
+ sig do
84
+ params(
85
+ trainset: T::Array[T.untyped],
86
+ batch_size: Integer,
87
+ rng: T.nilable(Random)
88
+ ).returns(T::Array[T.untyped])
89
+ end
90
+ def self.create_minibatch(trainset, batch_size = 50, rng = nil)
91
+ # Ensure batch_size isn't larger than the size of the dataset
92
+ actual_batch_size = [batch_size, trainset.size].min
93
+
94
+ # Randomly sample from trainset
95
+ # If RNG is provided, use it for reproducible sampling
96
+ if rng
97
+ trainset.sample(actual_batch_size, random: rng)
98
+ else
99
+ trainset.sample(actual_batch_size)
100
+ end
101
+ end
102
+
103
+ # Get program with highest average score from minibatch trials
104
+ # Used as a helper function for Bayesian + minibatching optimizers
105
+ #
106
+ # @param param_score_dict [Hash] Maps combo keys to arrays of [score, program, params] tuples
107
+ # @param fully_evaled_param_combos [Array] List of combo keys that have been fully evaluated
108
+ # @return [Array] Returns [program, mean_score, combo_key, params]
109
+ sig do
110
+ params(
111
+ param_score_dict: T::Hash[String, T::Array[T::Array[T.untyped]]],
112
+ fully_evaled_param_combos: T::Array[String]
113
+ ).returns([T.untyped, Float, String, T::Hash[Symbol, T.untyped]])
114
+ end
115
+ def self.get_program_with_highest_avg_score(param_score_dict, fully_evaled_param_combos)
116
+ # Calculate the mean for each combination of categorical parameters, based on past trials
117
+ results = []
118
+ param_score_dict.each do |key, values|
119
+ scores = values.map { |v| v[0] }
120
+ mean = scores.sum.to_f / scores.size
121
+ program = values[0][1]
122
+ params = values[0][2]
123
+ results << [key, mean, program, params]
124
+ end
125
+
126
+ # Sort results by the mean in descending order
127
+ sorted_results = results.sort_by { |_key, mean, _program, _params| -mean }
128
+
129
+ # Find the combination with the highest mean, skip fully evaluated ones
130
+ sorted_results.each do |key, mean, program, params|
131
+ next if fully_evaled_param_combos.include?(key)
132
+ return [program, mean, key, params]
133
+ end
134
+
135
+ # If no valid program is found, return the last valid one
136
+ _key, mean, program, params = sorted_results.last
137
+ [program, mean, _key, params]
138
+ end
139
+
140
+ # Save a candidate program to the log directory
141
+ # Used during optimization to save intermediate trial results
142
+ #
143
+ # @param program [Module] The program to save
144
+ # @param log_dir [String, nil] The directory to save to (returns nil if nil)
145
+ # @param trial_num [Integer] The trial number for naming the file
146
+ # @param note [String, nil] Optional note to append to filename
147
+ # @return [String, nil] The path where program was saved, or nil if log_dir is nil
148
+ sig do
149
+ params(
150
+ program: T.untyped,
151
+ log_dir: T.nilable(String),
152
+ trial_num: Integer,
153
+ note: T.nilable(String)
154
+ ).returns(T.nilable(String))
155
+ end
156
+ def self.save_candidate_program(program, log_dir, trial_num, note: nil)
157
+ return nil if log_dir.nil?
158
+
159
+ # Ensure the directory exists
160
+ eval_programs_dir = File.join(log_dir, "evaluated_programs")
161
+ FileUtils.mkdir_p(eval_programs_dir) unless Dir.exist?(eval_programs_dir)
162
+
163
+ # Define the save path for the program
164
+ filename = if note
165
+ "program_#{trial_num}_#{note}.json"
166
+ else
167
+ "program_#{trial_num}.json"
168
+ end
169
+ save_path = File.join(eval_programs_dir, filename)
170
+
171
+ # Save the program
172
+ program.save(save_path)
173
+
174
+ save_path
175
+ end
176
+
15
177
  # Configuration for bootstrap operations
16
178
  class BootstrapConfig
17
179
  extend T::Sig
@@ -50,6 +212,9 @@ module DSPy
50
212
  end
51
213
 
52
214
  # Result of bootstrap operation
215
+ # @deprecated This class is deprecated and kept only for backward compatibility.
216
+ # The new create_n_fewshot_demo_sets returns a Hash{predictor_idx => [[demos]]}
217
+ # instead of this BootstrapResult object. Use the dict interface directly.
53
218
  class BootstrapResult
54
219
  extend T::Sig
55
220
 
@@ -93,58 +258,195 @@ module DSPy
93
258
  end
94
259
  end
95
260
 
96
- # Create multiple candidate sets of few-shot examples through bootstrapping
261
+ # Create multiple candidate sets of few-shot demonstrations using different bootstrap strategies
262
+ #
263
+ # This is the Python-compatible implementation that uses a seed-based loop to create
264
+ # demo sets using 4 strategies: ZeroShot (-3), LabeledOnly (-2), Unshuffled (-1), and Shuffled (>=0)
265
+ #
266
+ # @param student [DSPy::Module] The student program to bootstrap
267
+ # @param num_candidate_sets [Integer] Number of demo sets to create (accounts for special seeds)
268
+ # @param trainset [Array<DSPy::Example>] Training examples
269
+ # @param max_bootstrapped_demos [Integer] Maximum bootstrapped demonstrations per set
270
+ # @param max_labeled_demos [Integer] Maximum labeled demonstrations to prepend
271
+ # @param min_num_samples [Integer] Minimum number of samples for shuffled strategy
272
+ # @param metric [Proc] Optional metric to validate bootstrapped examples
273
+ # @param teacher_settings [Hash] Settings for teacher program (future use)
274
+ # @param seed [Integer] Random seed for reproducibility
275
+ # @param include_non_bootstrapped [Boolean] Include ZeroShot and LabeledOnly strategies
276
+ # @param labeled_sample [Boolean] Whether to sample labeled examples randomly
277
+ # @return [Hash{Integer => Array<Array<DSPy::FewShotExample>>}] Map of predictor index to demo sets
97
278
  sig do
98
279
  params(
99
- program: T.untyped,
280
+ student: T.untyped,
281
+ num_candidate_sets: Integer,
100
282
  trainset: T::Array[T.untyped],
101
- config: BootstrapConfig,
102
- metric: T.nilable(T.proc.params(arg0: T.untyped, arg1: T.untyped).returns(T::Boolean))
103
- ).returns(BootstrapResult)
283
+ max_bootstrapped_demos: Integer,
284
+ max_labeled_demos: Integer,
285
+ min_num_samples: Integer,
286
+ metric: T.nilable(T.proc.params(arg0: T.untyped, arg1: T.untyped).returns(T::Boolean)),
287
+ teacher_settings: T::Hash[Symbol, T.untyped],
288
+ seed: T.nilable(Integer),
289
+ include_non_bootstrapped: T::Boolean,
290
+ labeled_sample: T::Boolean
291
+ ).returns(T::Hash[Integer, T::Array[T::Array[DSPy::FewShotExample]]])
104
292
  end
105
- def self.create_n_fewshot_demo_sets(program, trainset, config: BootstrapConfig.new, metric: nil)
106
- DSPy::Context.with_span(
107
- operation: 'optimization.bootstrap_start',
108
- 'dspy.module' => 'Bootstrap',
109
- 'bootstrap.trainset_size' => trainset.size,
110
- 'bootstrap.max_examples' => config.max_bootstrapped_examples,
111
- 'bootstrap.num_candidate_sets' => config.num_candidate_sets
112
- ) do
113
- # Convert to typed examples if needed
114
- typed_examples = ensure_typed_examples(trainset)
115
-
116
- # Generate successful examples through bootstrap
117
- successful_examples, failed_examples = generate_successful_examples(
118
- program,
119
- typed_examples,
120
- config,
121
- metric
122
- )
293
+ def self.create_n_fewshot_demo_sets(
294
+ student,
295
+ num_candidate_sets,
296
+ trainset,
297
+ max_bootstrapped_demos: 3,
298
+ max_labeled_demos: 3,
299
+ min_num_samples: 1,
300
+ metric: nil,
301
+ teacher_settings: {},
302
+ seed: nil,
303
+ include_non_bootstrapped: true,
304
+ labeled_sample: true
305
+ )
306
+ demo_candidates = Hash.new { |h, k| h[k] = [] }
307
+ rng = seed ? Random.new(seed) : Random.new
308
+
309
+ # Get number of predictors (simplified: assume single predictor)
310
+ num_predictors = 1
311
+
312
+ # Adjust for 3 special seeds (-3, -2, -1)
313
+ adjusted_num_sets = num_candidate_sets - 3
314
+
315
+ # Loop from -3 to adjusted_num_sets (exclusive)
316
+ (-3...adjusted_num_sets).each do |current_seed|
317
+ case current_seed
318
+ when -3 # ZeroShot strategy
319
+ next unless include_non_bootstrapped
320
+ # Empty demo sets for all predictors
321
+ num_predictors.times { |idx| demo_candidates[idx] << [] }
322
+
323
+ when -2 # LabeledOnly strategy
324
+ next unless include_non_bootstrapped && max_labeled_demos > 0
325
+ # Sample or take labeled examples
326
+ labeled_demos = create_labeled_demos(trainset, max_labeled_demos, labeled_sample, rng)
327
+ num_predictors.times { |idx| demo_candidates[idx] << labeled_demos }
328
+
329
+ when -1 # Unshuffled strategy
330
+ # Bootstrap without shuffle
331
+ bootstrapped_demos = create_bootstrapped_demos(
332
+ student, trainset, max_bootstrapped_demos, max_labeled_demos, metric
333
+ )
334
+ num_predictors.times { |idx| demo_candidates[idx] << bootstrapped_demos }
335
+
336
+ else # Shuffled strategies (seed >= 0)
337
+ # Shuffle trainset with current seed
338
+ seed_rng = Random.new(current_seed)
339
+ shuffled_trainset = trainset.shuffle(random: seed_rng)
340
+
341
+ # Random demo count between min and max
342
+ num_demos = seed_rng.rand(min_num_samples..max_bootstrapped_demos)
343
+
344
+ # Bootstrap with shuffled data
345
+ bootstrapped_demos = create_bootstrapped_demos(
346
+ student, shuffled_trainset, num_demos, max_labeled_demos, metric
347
+ )
348
+ num_predictors.times { |idx| demo_candidates[idx] << bootstrapped_demos }
349
+ end
350
+ end
123
351
 
124
- # Create candidate sets from successful examples
125
- candidate_sets = create_candidate_sets(successful_examples, config)
126
-
127
- # Gather statistics
128
- statistics = {
129
- total_trainset: trainset.size,
130
- successful_count: successful_examples.size,
131
- failed_count: failed_examples.size,
132
- success_rate: successful_examples.size.to_f / (successful_examples.size + failed_examples.size),
133
- candidate_sets_created: candidate_sets.size,
134
- average_set_size: candidate_sets.empty? ? 0 : candidate_sets.map(&:size).sum.to_f / candidate_sets.size
135
- }
352
+ demo_candidates
353
+ end
136
354
 
137
- emit_bootstrap_complete_event(statistics)
355
+ # Create labeled demonstrations from trainset examples
356
+ sig do
357
+ params(
358
+ trainset: T::Array[T.untyped],
359
+ max_labeled: Integer,
360
+ labeled_sample: T::Boolean,
361
+ rng: Random
362
+ ).returns(T::Array[DSPy::FewShotExample])
363
+ end
364
+ def self.create_labeled_demos(trainset, max_labeled, labeled_sample, rng)
365
+ examples = if labeled_sample
366
+ trainset.sample([max_labeled, trainset.size].min, random: rng)
367
+ else
368
+ trainset.take(max_labeled)
369
+ end
138
370
 
139
- BootstrapResult.new(
140
- candidate_sets: candidate_sets,
141
- successful_examples: successful_examples,
142
- failed_examples: failed_examples,
143
- statistics: statistics
371
+ examples.map do |ex|
372
+ DSPy::FewShotExample.new(
373
+ input: ex.input_values,
374
+ output: ex.expected_values
144
375
  )
145
376
  end
146
377
  end
147
378
 
379
+ # Create bootstrapped demonstrations by executing student on trainset
380
+ sig do
381
+ params(
382
+ student: T.untyped,
383
+ trainset: T::Array[T.untyped],
384
+ max_bootstrapped: Integer,
385
+ max_labeled: Integer,
386
+ metric: T.nilable(T.proc.params(arg0: T.untyped, arg1: T.untyped).returns(T::Boolean))
387
+ ).returns(T::Array[DSPy::FewShotExample])
388
+ end
389
+ def self.create_bootstrapped_demos(student, trainset, max_bootstrapped, max_labeled, metric)
390
+ successful_demos = []
391
+
392
+ # Execute student on trainset to bootstrap demonstrations
393
+ trainset.each do |example|
394
+ break if successful_demos.size >= max_bootstrapped
395
+
396
+ begin
397
+ # Call student with input
398
+ prediction = student.call(**example.input_values)
399
+ prediction_hash = prediction.respond_to?(:to_h) ? prediction.to_h : prediction
400
+
401
+ # Check if prediction matches expected output
402
+ success = if metric
403
+ metric.call(example, prediction_hash)
404
+ else
405
+ example.matches_prediction?(prediction_hash)
406
+ end
407
+
408
+ if success
409
+ # Extract only output fields from prediction
410
+ output_fields = extract_output_fields_for_demo(prediction_hash, example.signature_class)
411
+
412
+ demo = DSPy::FewShotExample.new(
413
+ input: example.input_values,
414
+ output: output_fields
415
+ )
416
+ successful_demos << demo
417
+ end
418
+ rescue => e
419
+ # Continue on errors
420
+ DSPy.logger.warn("Bootstrap error: #{e.message}") if DSPy.logger
421
+ end
422
+ end
423
+
424
+ # Prepend labeled examples if requested
425
+ if max_labeled > 0
426
+ labeled = trainset.take(max_labeled).map do |ex|
427
+ DSPy::FewShotExample.new(
428
+ input: ex.input_values,
429
+ output: ex.expected_values
430
+ )
431
+ end
432
+ successful_demos = labeled + successful_demos
433
+ end
434
+
435
+ successful_demos
436
+ end
437
+
438
+ # Extract only output fields from prediction hash
439
+ sig do
440
+ params(
441
+ prediction_hash: T::Hash[Symbol, T.untyped],
442
+ signature_class: T.class_of(DSPy::Signature)
443
+ ).returns(T::Hash[Symbol, T.untyped])
444
+ end
445
+ def self.extract_output_fields_for_demo(prediction_hash, signature_class)
446
+ output_field_names = signature_class.output_field_descriptors.keys
447
+ prediction_hash.slice(*output_field_names)
448
+ end
449
+
148
450
  # Evaluate a candidate program on examples with proper error handling
149
451
  sig do
150
452
  params(
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.28.0"
4
+ VERSION = "0.28.2"
5
5
  end
data/lib/dspy.rb CHANGED
@@ -191,6 +191,7 @@ module DSPy
191
191
  end
192
192
  end
193
193
 
194
+ require_relative 'dspy/callbacks'
194
195
  require_relative 'dspy/module'
195
196
  require_relative 'dspy/field'
196
197
  require_relative 'dspy/signature'
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.28.0
4
+ version: 0.28.2
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-10-02 00:00:00.000000000 Z
10
+ date: 2025-10-13 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: dry-configurable
@@ -121,6 +121,20 @@ dependencies:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
123
  version: '0.3'
124
+ - !ruby/object:Gem::Dependency
125
+ name: sorbet-baml
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '0.1'
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '0.1'
124
138
  - !ruby/object:Gem::Dependency
125
139
  name: numo-narray
126
140
  requirement: !ruby/object:Gem::Requirement
@@ -189,6 +203,7 @@ extra_rdoc_files: []
189
203
  files:
190
204
  - README.md
191
205
  - lib/dspy.rb
206
+ - lib/dspy/callbacks.rb
192
207
  - lib/dspy/chain_of_thought.rb
193
208
  - lib/dspy/code_act.rb
194
209
  - lib/dspy/context.rb
@@ -239,6 +254,7 @@ files:
239
254
  - lib/dspy/predict.rb
240
255
  - lib/dspy/prediction.rb
241
256
  - lib/dspy/prompt.rb
257
+ - lib/dspy/propose/dataset_summary_generator.rb
242
258
  - lib/dspy/propose/grounded_proposer.rb
243
259
  - lib/dspy/re_act.rb
244
260
  - lib/dspy/registry/registry_manager.rb
@@ -247,6 +263,8 @@ files:
247
263
  - lib/dspy/signature.rb
248
264
  - lib/dspy/storage/program_storage.rb
249
265
  - lib/dspy/storage/storage_manager.rb
266
+ - lib/dspy/structured_outputs_prompt.rb
267
+ - lib/dspy/teleprompt/bootstrap_strategy.rb
250
268
  - lib/dspy/teleprompt/data_handler.rb
251
269
  - lib/dspy/teleprompt/gepa.rb
252
270
  - lib/dspy/teleprompt/mipro_v2.rb