dspy 0.28.1 → 0.29.0

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -3
  3. data/lib/dspy/callbacks.rb +222 -0
  4. data/lib/dspy/chain_of_thought.rb +2 -1
  5. data/lib/dspy/code_act.rb +14 -1
  6. data/lib/dspy/datasets/ade.rb +90 -0
  7. data/lib/dspy/datasets.rb +8 -0
  8. data/lib/dspy/lm.rb +9 -12
  9. data/lib/dspy/mixins/struct_builder.rb +17 -25
  10. data/lib/dspy/module.rb +45 -1
  11. data/lib/dspy/observability/async_span_processor.rb +67 -93
  12. data/lib/dspy/observability.rb +43 -1
  13. data/lib/dspy/predict.rb +17 -0
  14. data/lib/dspy/prompt.rb +90 -20
  15. data/lib/dspy/propose/dataset_summary_generator.rb +210 -0
  16. data/lib/dspy/propose/grounded_proposer.rb +320 -66
  17. data/lib/dspy/re_act.rb +13 -0
  18. data/lib/dspy/reflection_lm.rb +36 -0
  19. data/lib/dspy/teleprompt/bootstrap_strategy.rb +26 -0
  20. data/lib/dspy/teleprompt/gepa.rb +448 -2803
  21. data/lib/dspy/teleprompt/mipro_v2.rb +624 -100
  22. data/lib/dspy/teleprompt/utils.rb +349 -42
  23. data/lib/dspy/version.rb +2 -2
  24. data/lib/dspy.rb +4 -2
  25. data/lib/gepa/api.rb +61 -0
  26. data/lib/gepa/core/engine.rb +226 -0
  27. data/lib/gepa/core/evaluation_batch.rb +26 -0
  28. data/lib/gepa/core/result.rb +92 -0
  29. data/lib/gepa/core/state.rb +231 -0
  30. data/lib/gepa/logging/experiment_tracker.rb +54 -0
  31. data/lib/gepa/logging/logger.rb +57 -0
  32. data/lib/gepa/logging.rb +9 -0
  33. data/lib/gepa/proposer/base.rb +27 -0
  34. data/lib/gepa/proposer/merge_proposer.rb +424 -0
  35. data/lib/gepa/proposer/reflective_mutation/base.rb +48 -0
  36. data/lib/gepa/proposer/reflective_mutation/reflective_mutation.rb +188 -0
  37. data/lib/gepa/strategies/batch_sampler.rb +91 -0
  38. data/lib/gepa/strategies/candidate_selector.rb +97 -0
  39. data/lib/gepa/strategies/component_selector.rb +57 -0
  40. data/lib/gepa/strategies/instruction_proposal.rb +120 -0
  41. data/lib/gepa/telemetry.rb +122 -0
  42. data/lib/gepa/utils/pareto.rb +119 -0
  43. data/lib/gepa.rb +21 -0
  44. metadata +59 -4
  45. data/lib/dspy/teleprompt/simple_optimizer.rb +0 -497
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b377060443eeb9c3c5d975e76750d6c519d1b93cf6f20dc6ad20bcda08d1ca4
4
- data.tar.gz: 4580845b3fd9991b531c8c2bd809595cbd3328da57a23e58307cdbe52e3822bc
3
+ metadata.gz: 747119ce407283e4d8ed5f01014262f24a94418ad2cbef4305a28b21cb58c8bc
4
+ data.tar.gz: 3693faccd1fca98015864fd4404491b619b2aa600ab83a78dd3fc7d9e3342ef1
5
5
  SHA512:
6
- metadata.gz: 444a5e08364b2e996bf49d230cb9a94a1930e26d2ea796cd0104ea4888b45ade4099cec502b92c08752631adff47b1d9d1897da9209e9faabbb28fb6761935b0
7
- data.tar.gz: c1b3a83482c861923304c463d6f0b1c9042fbc31da920526ec18ed0f5148b4408a9023d312eded9263ca2d706e4779b78526e28dc50a17458cbbac9afa56b024
6
+ metadata.gz: 7fecac3bc3389e11bdb2328234455cbbbcbf6c7544518cbf94082e75a3f0bde489339b70f91dfd31a43bbd87b17451bfb5a78387c90f5b669aefa09e8af73500
7
+ data.tar.gz: 01feee252179dd66016a8633658631dacc2262f5598ee07d4f78ec2947ebbb57cd15b19b433b8ea9a87b7120fbf5afa984fd929677de0fcfaf7a4368e5b29d85
data/README.md CHANGED
@@ -112,7 +112,6 @@ end
112
112
  - **Typed Examples** - Type-safe training data with automatic validation
113
113
  - **Evaluation Framework** - Advanced metrics beyond simple accuracy with error-resilient pipelines
114
114
  - **MIPROv2 Optimization** - Advanced Bayesian optimization with Gaussian Processes, multiple optimization strategies, and storage persistence
115
- - **GEPA Optimization** - Genetic-Pareto optimization for multi-objective prompt improvement
116
115
 
117
116
  **Production Features:**
118
117
  - **Reliable JSON Extraction** - Native structured outputs for OpenAI and Gemini, Anthropic tool-based extraction, and automatic strategy selection with fallback
@@ -168,11 +167,11 @@ For LLMs and AI assistants working with DSPy.rb:
168
167
  - **[Evaluation Framework](docs/src/optimization/evaluation.md)** - Advanced metrics beyond simple accuracy
169
168
  - **[Prompt Optimization](docs/src/optimization/prompt-optimization.md)** - Manipulate prompts as objects
170
169
  - **[MIPROv2 Optimizer](docs/src/optimization/miprov2.md)** - Advanced Bayesian optimization with Gaussian Processes
171
- - **[GEPA Optimizer](docs/src/optimization/gepa.md)** - Genetic-Pareto optimization for multi-objective prompt optimization
170
+ - **[GEPA Optimizer](docs/src/optimization/gepa.md)** *(beta)* - Reflective mutation with optional reflection LMs
172
171
 
173
172
  ### Production Features
174
173
  - **[Storage System](docs/src/production/storage.md)** - Persistence and optimization result storage
175
- - **[Observability](docs/src/production/observability.md)** - Zero-config Langfuse integration and structured logging
174
+ - **[Observability](docs/src/production/observability.md)** - Zero-config Langfuse integration with a dedicated export worker that never blocks your LLMs
176
175
 
177
176
  ### Advanced Usage
178
177
  - **[Complex Types](docs/src/advanced/complex-types.md)** - Sorbet type integration with automatic coercion for structs, enums, and arrays
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ # Provides Rails-style callback hooks for DSPy modules
7
+ #
8
+ # @example Define callbacks in base class
9
+ # class DSPy::Module
10
+ # include DSPy::Callbacks
11
+ #
12
+ # create_before_callback :forward
13
+ # create_after_callback :forward
14
+ # create_around_callback :forward
15
+ # end
16
+ #
17
+ # @example Use callbacks in subclasses
18
+ # class MyAgent < DSPy::Module
19
+ # before :setup_context
20
+ # after :log_metrics
21
+ # around :manage_memory
22
+ #
23
+ # private
24
+ #
25
+ # def setup_context
26
+ # @start_time = Time.now
27
+ # end
28
+ #
29
+ # def log_metrics
30
+ # puts "Duration: #{Time.now - @start_time}"
31
+ # end
32
+ #
33
+ # def manage_memory
34
+ # load_context
35
+ # yield
36
+ # save_context
37
+ # end
38
+ # end
39
+ module Callbacks
40
+ def self.included(base)
41
+ base.extend(ClassMethods)
42
+ end
43
+
44
+ module ClassMethods
45
+ # Creates a before callback hook for the specified method
46
+ #
47
+ # @param method_name [Symbol] the method to add callback support to
48
+ def create_before_callback(method_name)
49
+ mark_method_has_callbacks(method_name)
50
+ ensure_callback_method_defined(:before, method_name)
51
+ wrap_method_with_callbacks(method_name)
52
+ end
53
+
54
+ # Creates an after callback hook for the specified method
55
+ #
56
+ # @param method_name [Symbol] the method to add callback support to
57
+ def create_after_callback(method_name)
58
+ mark_method_has_callbacks(method_name)
59
+ ensure_callback_method_defined(:after, method_name)
60
+ wrap_method_with_callbacks(method_name)
61
+ end
62
+
63
+ # Creates an around callback hook for the specified method
64
+ #
65
+ # @param method_name [Symbol] the method to add callback support to
66
+ def create_around_callback(method_name)
67
+ mark_method_has_callbacks(method_name)
68
+ ensure_callback_method_defined(:around, method_name)
69
+ wrap_method_with_callbacks(method_name)
70
+ end
71
+
72
+ private
73
+
74
+ # Ensures the callback registration method exists
75
+ def ensure_callback_method_defined(type, target_method_name)
76
+ return if singleton_class.method_defined?(type)
77
+
78
+ define_singleton_method(type) do |callback_method|
79
+ register_callback(type, target_method_name, callback_method)
80
+ end
81
+ end
82
+
83
+ # Registers a callback for execution
84
+ def register_callback(type, method_name, callback_method)
85
+ own_callbacks_for(method_name)[type] ||= []
86
+ own_callbacks_for(method_name)[type] << callback_method
87
+ end
88
+
89
+ # Returns own callbacks (not including parent)
90
+ def own_callbacks_for(method_name)
91
+ @callbacks ||= {}
92
+ @callbacks[method_name] ||= {}
93
+ end
94
+
95
+ # Marks that a method has callback support (even if no callbacks registered yet)
96
+ def mark_method_has_callbacks(method_name)
97
+ own_callbacks_for(method_name)
98
+ end
99
+
100
+ # Returns the callback registry for a method
101
+ # Includes callbacks from parent classes
102
+ def callbacks_for(method_name)
103
+ own_callbacks = own_callbacks_for(method_name)
104
+
105
+ # Merge parent callbacks if this is a subclass
106
+ if superclass.respond_to?(:callbacks_for, true)
107
+ parent_callbacks = superclass.send(:callbacks_for, method_name)
108
+
109
+ # Merge each callback type, with own callbacks coming after parent callbacks
110
+ merged_callbacks = {}
111
+ [:before, :after, :around].each do |type|
112
+ parent_list = parent_callbacks[type] || []
113
+ own_list = own_callbacks[type] || []
114
+ merged_callbacks[type] = parent_list + own_list if parent_list.any? || own_list.any?
115
+ end
116
+
117
+ merged_callbacks
118
+ else
119
+ own_callbacks
120
+ end
121
+ end
122
+
123
+ # Wraps a method with callback execution logic
124
+ def wrap_method_with_callbacks(method_name)
125
+ return if method_wrapped?(method_name)
126
+
127
+ # Defer wrapping if method doesn't exist yet
128
+ return unless method_defined?(method_name)
129
+
130
+ # Mark as wrapped BEFORE define_method to prevent infinite recursion
131
+ mark_method_wrapped(method_name)
132
+
133
+ original_method = instance_method(method_name)
134
+
135
+ define_method(method_name) do |*args, **kwargs, &block|
136
+ # Execute before callbacks
137
+ run_callbacks(:before, method_name)
138
+
139
+ # Execute around callbacks or original method
140
+ result = if self.class.send(:has_around_callbacks?, method_name)
141
+ execute_with_around_callbacks(method_name, original_method, *args, **kwargs, &block)
142
+ else
143
+ original_method.bind(self).call(*args, **kwargs, &block)
144
+ end
145
+
146
+ # Execute after callbacks
147
+ run_callbacks(:after, method_name)
148
+
149
+ result
150
+ end
151
+ end
152
+
153
+ # Checks if method has around callbacks
154
+ def has_around_callbacks?(method_name)
155
+ callbacks_for(method_name)[:around]&.any?
156
+ end
157
+
158
+ # Hook into method_added to wrap methods when they're defined
159
+ def method_added(method_name)
160
+ super
161
+
162
+ # Check if this method or any parent has callback support (even if no callbacks registered yet)
163
+ has_callback_support = method_has_callback_support?(method_name)
164
+
165
+ return unless has_callback_support
166
+ return if method_wrapped?(method_name)
167
+
168
+ wrap_method_with_callbacks(method_name)
169
+ end
170
+
171
+ # Checks if a method has callback support in this class or parents
172
+ def method_has_callback_support?(method_name)
173
+ # Check own callbacks registry
174
+ return true if @callbacks&.key?(method_name)
175
+
176
+ # Check parent class
177
+ if superclass.respond_to?(:method_has_callback_support?, true)
178
+ superclass.send(:method_has_callback_support?, method_name)
179
+ else
180
+ false
181
+ end
182
+ end
183
+
184
+ # Marks a method as wrapped
185
+ def mark_method_wrapped(method_name)
186
+ @wrapped_methods ||= []
187
+ @wrapped_methods << method_name
188
+ end
189
+
190
+ # Checks if method is already wrapped
191
+ def method_wrapped?(method_name)
192
+ @wrapped_methods&.include?(method_name)
193
+ end
194
+ end
195
+
196
+ private
197
+
198
+ # Executes callbacks of a specific type
199
+ def run_callbacks(type, method_name)
200
+ callbacks = self.class.send(:callbacks_for, method_name)[type]
201
+ return unless callbacks
202
+
203
+ callbacks.each do |callback_method|
204
+ send(callback_method)
205
+ end
206
+ end
207
+
208
+ # Executes method with around callbacks
209
+ def execute_with_around_callbacks(method_name, original_method, *args, **kwargs, &block)
210
+ callbacks = self.class.send(:callbacks_for, method_name)[:around]
211
+
212
+ # Build callback chain from innermost (original method) to outermost
213
+ chain = callbacks.reverse.inject(
214
+ -> { original_method.bind(self).call(*args, **kwargs, &block) }
215
+ ) do |inner, callback_method|
216
+ -> { send(callback_method) { inner.call } }
217
+ end
218
+
219
+ chain.call
220
+ end
221
+ end
222
+ end
@@ -46,7 +46,8 @@ module DSPy
46
46
  input_schema: @signature_class.input_json_schema,
47
47
  output_schema: @signature_class.output_json_schema,
48
48
  few_shot_examples: new_prompt.few_shot_examples,
49
- signature_class_name: @signature_class.name
49
+ signature_class_name: @signature_class.name,
50
+ schema_format: new_prompt.schema_format
50
51
  )
51
52
 
52
53
  instance.instance_variable_set(:@prompt, enhanced_prompt)
data/lib/dspy/code_act.rb CHANGED
@@ -146,6 +146,19 @@ module DSPy
146
146
  super(enhanced_signature)
147
147
  end
148
148
 
149
+ sig { override.returns(T::Array[[String, DSPy::Module]]) }
150
+ def named_predictors
151
+ pairs = T.let([], T::Array[[String, DSPy::Module]])
152
+ pairs << ["code_generator", @code_generator]
153
+ pairs << ["observation_processor", @observation_processor]
154
+ pairs
155
+ end
156
+
157
+ sig { override.returns(T::Array[DSPy::Module]) }
158
+ def predictors
159
+ named_predictors.map { |(_, predictor)| predictor }
160
+ end
161
+
149
162
  sig { params(kwargs: T.untyped).returns(T.untyped).override }
150
163
  def forward(**kwargs)
151
164
  # Validate input and serialize all fields as task context
@@ -461,4 +474,4 @@ module DSPy
461
474
  example
462
475
  end
463
476
  end
464
- end
477
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+ require 'cgi'
7
+ require 'fileutils'
8
+
9
+ module DSPy
10
+ module Datasets
11
+ module ADE
12
+ extend self
13
+
14
+ DATASET = 'ade-benchmark-corpus/ade_corpus_v2'
15
+ CLASSIFICATION_CONFIG = 'Ade_corpus_v2_classification'
16
+ BASE_URL = 'https://datasets-server.huggingface.co'
17
+
18
+ DEFAULT_CACHE_DIR = File.expand_path('../../../tmp/dspy_datasets/ade', __dir__)
19
+
20
+ MAX_BATCH_SIZE = 100
21
+
22
+ def examples(split: 'train', limit: 200, offset: 0, cache_dir: default_cache_dir)
23
+ remaining = limit
24
+ current_offset = offset
25
+ collected = []
26
+
27
+ while remaining.positive?
28
+ batch_size = [remaining, MAX_BATCH_SIZE].min
29
+ rows = fetch_rows(
30
+ split: split,
31
+ limit: batch_size,
32
+ offset: current_offset,
33
+ cache_dir: cache_dir
34
+ )
35
+
36
+ break if rows.empty?
37
+
38
+ collected.concat(rows.map do |row|
39
+ {
40
+ 'text' => row.fetch('text', ''),
41
+ 'label' => row.fetch('label', 0).to_i
42
+ }
43
+ end)
44
+
45
+ current_offset += batch_size
46
+ remaining -= batch_size
47
+ end
48
+
49
+ collected
50
+ end
51
+
52
+ def fetch_rows(split:, limit:, offset:, cache_dir:)
53
+ FileUtils.mkdir_p(cache_dir)
54
+ cache_path = File.join(cache_dir, "#{CLASSIFICATION_CONFIG}_#{split}_#{offset}_#{limit}.json")
55
+
56
+ if File.exist?(cache_path)
57
+ return JSON.parse(File.read(cache_path))
58
+ end
59
+
60
+ rows = request_rows(split: split, limit: limit, offset: offset)
61
+ File.write(cache_path, JSON.pretty_generate(rows))
62
+ rows
63
+ end
64
+
65
+ private
66
+
67
+ def request_rows(split:, limit:, offset:)
68
+ uri = URI("#{BASE_URL}/rows")
69
+ params = {
70
+ dataset: DATASET,
71
+ config: CLASSIFICATION_CONFIG,
72
+ split: split,
73
+ offset: offset,
74
+ length: limit
75
+ }
76
+ uri.query = URI.encode_www_form(params)
77
+
78
+ response = Net::HTTP.get_response(uri)
79
+ raise "ADE dataset request failed: #{response.code}" unless response.is_a?(Net::HTTPSuccess)
80
+
81
+ body = JSON.parse(response.body)
82
+ body.fetch('rows', []).map { |row| row.fetch('row', {}) }
83
+ end
84
+
85
+ def default_cache_dir
86
+ ENV['DSPY_DATASETS_CACHE'] ? File.expand_path('ade', ENV['DSPY_DATASETS_CACHE']) : DEFAULT_CACHE_DIR
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'datasets/ade'
4
+
5
+ module DSPy
6
+ module Datasets
7
+ end
8
+ end
data/lib/dspy/lm.rb CHANGED
@@ -31,15 +31,16 @@ require_relative 'structured_outputs_prompt'
31
31
  module DSPy
32
32
  class LM
33
33
  extend T::Sig
34
- attr_reader :model_id, :api_key, :model, :provider, :adapter
34
+ attr_reader :model_id, :api_key, :model, :provider, :adapter, :schema_format
35
35
 
36
- def initialize(model_id, api_key: nil, **options)
36
+ def initialize(model_id, api_key: nil, schema_format: :json, **options)
37
37
  @model_id = model_id
38
38
  @api_key = api_key
39
-
39
+ @schema_format = schema_format
40
+
40
41
  # Parse provider and model from model_id
41
42
  @provider, @model = parse_model_id(model_id)
42
-
43
+
43
44
  # Create appropriate adapter with options
44
45
  @adapter = AdapterFactory.create(model_id, api_key: api_key, **options)
45
46
  end
@@ -66,9 +67,6 @@ module DSPy
66
67
  chat_with_strategy(messages, signature_class, &block)
67
68
  end
68
69
 
69
- # Emit the standard lm.tokens event (consistent with raw_chat)
70
- emit_token_usage(response, signature_class.name)
71
-
72
70
  # Parse response (no longer needs separate instrumentation)
73
71
  parsed_result = parse_response(response, input_values, signature_class)
74
72
 
@@ -270,7 +268,7 @@ module DSPy
270
268
  'dspy.signature' => signature_class_name
271
269
  ) do |span|
272
270
  result = execution_block.call
273
-
271
+
274
272
  # Add output and usage data directly to span
275
273
  if span && result
276
274
  # Add completion output
@@ -292,7 +290,9 @@ module DSPy
292
290
  span.set_attribute('gen_ai.usage.total_tokens', usage.total_tokens) if usage.total_tokens
293
291
  end
294
292
  end
295
-
293
+
294
+ emit_token_usage(result, signature_class_name)
295
+
296
296
  result
297
297
  end
298
298
 
@@ -409,9 +409,6 @@ module DSPy
409
409
  adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
410
410
  end
411
411
 
412
- # Emit the standard lm.tokens event (consistent with other LM calls)
413
- emit_token_usage(response, 'RawPrompt')
414
-
415
412
  # Return raw response content, not parsed JSON
416
413
  response.content
417
414
  ensure
@@ -19,20 +19,20 @@ module DSPy
19
19
 
20
20
  Class.new(T::Struct) do
21
21
  extend T::Sig
22
+ define_field = lambda do |name, type, options|
23
+ const_kwargs = {}
24
+ const_kwargs[:default] = options[:default] if options.key?(:default)
25
+ const_kwargs[:factory] = options[:factory] if options.key?(:factory)
26
+ const_kwargs[:override] = true if props.key?(name)
27
+ const name, type, **const_kwargs
28
+ end
22
29
 
23
30
  # Add properties from each source
24
31
  property_sources.each do |_source_name, props|
25
32
  props.each do |name, prop|
26
33
  type = builder.send(:extract_type_from_prop, prop)
27
34
  options = builder.send(:extract_options_from_prop, prop)
28
-
29
- if options[:default]
30
- const name, type, default: options[:default]
31
- elsif options[:factory]
32
- const name, type, factory: options[:factory]
33
- else
34
- const name, type
35
- end
35
+ define_field.call(name, type, options)
36
36
  end
37
37
  end
38
38
 
@@ -40,14 +40,7 @@ module DSPy
40
40
  additional_fields.each do |name, field_config|
41
41
  type = builder.send(:extract_type_from_prop, field_config)
42
42
  options = builder.send(:extract_options_from_prop, field_config)
43
-
44
- if options[:default]
45
- const name, type, default: options[:default]
46
- elsif options[:factory]
47
- const name, type, factory: options[:factory]
48
- else
49
- const name, type
50
- end
43
+ define_field.call(name, type, options)
51
44
  end
52
45
 
53
46
  include StructSerialization
@@ -65,14 +58,13 @@ module DSPy
65
58
  def build_single_property(name, prop)
66
59
  type = extract_type_from_prop(prop)
67
60
  options = extract_options_from_prop(prop)
68
-
69
- if options[:default]
70
- const name, type, default: options[:default]
71
- elsif options[:factory]
72
- const name, type, factory: options[:factory]
73
- else
74
- const name, type
75
- end
61
+
62
+ const_kwargs = {}
63
+ const_kwargs[:default] = options[:default] if options.key?(:default)
64
+ const_kwargs[:factory] = options[:factory] if options.key?(:factory)
65
+ const_kwargs[:override] = true if respond_to?(:props) && props.key?(name)
66
+
67
+ const name, type, **const_kwargs
76
68
  end
77
69
 
78
70
  # Extracts type from property configuration
@@ -142,4 +134,4 @@ module DSPy
142
134
  end
143
135
  end
144
136
  end
145
- end
137
+ end
data/lib/dspy/module.rb CHANGED
@@ -3,16 +3,23 @@
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
5
  require_relative 'context'
6
+ require_relative 'callbacks'
6
7
 
7
8
  module DSPy
8
9
  class Module
9
10
  extend T::Sig
10
11
  extend T::Generic
11
12
  include Dry::Configurable
13
+ include DSPy::Callbacks
12
14
 
13
15
  # Per-instance LM configuration
14
16
  setting :lm, default: nil
15
17
 
18
+ # Define callback hooks for forward method
19
+ create_before_callback :forward
20
+ create_after_callback :forward
21
+ create_around_callback :forward
22
+
16
23
  # The main forward method that users will call is generic and type parameterized
17
24
  sig do
18
25
  type_parameters(:I, :O)
@@ -72,5 +79,42 @@ module DSPy
72
79
  def lm
73
80
  config.lm || DSPy.current_lm
74
81
  end
82
+
83
+ # Save the module state to a JSON file
84
+ # Lightweight serialization for intermediate optimization trials
85
+ #
86
+ # @param path [String] Path to save the module state (JSON format)
87
+ sig { params(path: String).void }
88
+ def save(path)
89
+ require 'json'
90
+ require 'fileutils'
91
+
92
+ # Ensure parent directory exists
93
+ dir = File.dirname(path)
94
+ FileUtils.mkdir_p(dir) unless Dir.exist?(dir)
95
+
96
+ # Serialize module to JSON
97
+ File.write(path, JSON.pretty_generate(to_h))
98
+ end
99
+
100
+ # Default serialization method - subclasses can override
101
+ sig { returns(T::Hash[Symbol, T.untyped]) }
102
+ def to_h
103
+ {
104
+ class_name: self.class.name,
105
+ state: {}
106
+ }
107
+ end
108
+
109
+ # Discover nested predictor modules (Python parity helper)
110
+ sig { returns(T::Array[[String, DSPy::Module]]) }
111
+ def named_predictors
112
+ []
113
+ end
114
+
115
+ sig { returns(T::Array[DSPy::Module]) }
116
+ def predictors
117
+ named_predictors.map { |(_, predictor)| predictor }
118
+ end
75
119
  end
76
- end
120
+ end