dspy 0.30.0 → 0.30.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: 5e59fde98eb918cca54822bc6af704100c51b71081d8ac77e17e8df3cddcb016
4
- data.tar.gz: f8ff81ef1873fc1bffac8a209ddb4172586295e6ef1f46d6bb598045148fc3eb
3
+ metadata.gz: 0bdf656ce5715a455d3fb36427878ae507d8d4bd9a7f092a6b9752196f9ebc4b
4
+ data.tar.gz: cd21540e0eea82c1567c085b9ef2fd5c2d9742cd1ff08f855a16f4109893ebb2
5
5
  SHA512:
6
- metadata.gz: dc880712e317a8e88c188527b5e1ff906da8c03fe1cbc4251e265bd87c141e52a16b4498f2f9316c33543f3428fb3067fd80a0728ce33de81575f111b93d4553
7
- data.tar.gz: 7d1899b7e8a61b5d2c80fb8f237085171e9b64407f99135b22c341076e11867ee0c323d15c67c5428337606c869702ec9e6ada13edf27c38a6a40d8c83ef4f3b
6
+ metadata.gz: 992c191cbc6bbdd1bc770a83d4a1151869c4f57a20d7236a19b0add9c50329ec2dea901fe440488b2194fe96951d729c4509d33a9df8fb6c2dc7154c570e9c00
7
+ data.tar.gz: 82e67a674db952801adf40407d34daf0e2808f4c1b4d99da60f63c33fd2033b7f5a2deaefe63ea91bf4bcc7129e7580772877df3ca3b69bf9894d2454a5bdf38
data/README.md CHANGED
@@ -47,23 +47,67 @@ and
47
47
  bundle install
48
48
  ```
49
49
 
50
- ### Optional Sibling Gems
50
+ ### Your First Reliable Predictor
51
+
52
+ ```ruby
53
+
54
+ # Configure DSPy globablly to use your fave LLM - you can override this on an instance levle.
55
+ DSPy.configure do |c|
56
+ c.lm = DSPy::LM.new('openai/gpt-4o-mini',
57
+ api_key: ENV['OPENAI_API_KEY'],
58
+ structured_outputs: true) # Enable OpenAI's native JSON mode
59
+ end
60
+
61
+ # Define a signature for sentiment classification - instead of writing a full prompt!
62
+ class Classify < DSPy::Signature
63
+ description "Classify sentiment of a given sentence." # sets the goal of the underlying prompt
64
+
65
+ class Sentiment < T::Enum
66
+ enums do
67
+ Positive = new('positive')
68
+ Negative = new('negative')
69
+ Neutral = new('neutral')
70
+ end
71
+ end
72
+
73
+ # Structured Inputs: makes sure you are sending only valid prompt inputs to your model
74
+ input do
75
+ const :sentence, String, description: 'The sentence to analyze'
76
+ end
77
+
78
+ # Structured Outputs: your predictor will validate the output of the model too.
79
+ output do
80
+ const :sentiment, Sentiment, description: 'The sentiment of the sentence'
81
+ const :confidence, Float, description: 'A number between 0.0 and 1.0'
82
+ end
83
+ end
84
+
85
+ # Wire it to the simplest prompting technique - a Predictn.
86
+ classify = DSPy::Predict.new(Classify)
87
+ # it may raise an error if you mess the inputs or your LLM messes the outputs.
88
+ result = classify.call(sentence: "This book was super fun to read!")
89
+
90
+ puts result.sentiment # => #<Sentiment::Positive>
91
+ puts result.confidence # => 0.85
92
+ ```
93
+
94
+ ### Sibling Gems
51
95
 
52
- DSPy.rb ships multiple gems from this monorepo so you only install what you need. Add these alongside `dspy`:
96
+ DSPy.rb ships multiple gems from this monorepo so you can opt into features with heavier dependency trees (e.g., datasets pull in Polars/Arrow, MIPROv2 requires `numo-*` BLAS bindings) only when you need them. Add these alongside `dspy`:
53
97
 
54
98
  | Gem | Description | Status |
55
99
  | --- | --- | --- |
56
- | `dspy-schema` | Exposes `DSPy::TypeSystem::SorbetJsonSchema` for downstream reuse. | **Stable** (v1.0.0) |
57
- | `dspy-code_act` | Think-Code-Observe agents that synthesize and execute Ruby safely. | Preview (0.x) |
58
- | `dspy-datasets` | Dataset helpers plus Parquet/Polars tooling for richer evaluation corpora. | Preview (0.x) |
59
- | `dspy-evals` | High-throughput evaluation harness with metrics, callbacks, and regression fixtures. | Preview (0.x) |
60
- | `dspy-miprov2` | Bayesian optimization + Gaussian Process backend for the MIPROv2 teleprompter. | Preview (0.x) |
61
- | `dspy-gepa` | `DSPy::Teleprompt::GEPA`, reflection loops, experiment tracking, telemetry adapters. | Preview (mirrors `dspy` version) |
62
- | `gepa` | GEPA optimizer core (Pareto engine, telemetry, reflective proposer). | Preview (mirrors `dspy` version) |
63
- | `dspy-o11y` | Core observability APIs: `DSPy::Observability`, async span processor, observation types. | **Stable** (v1.0.0) |
64
- | `dspy-o11y-langfuse` | Auto-configures DSPy observability to stream spans to Langfuse via OTLP. | **Stable** (v1.0.0) |
65
-
66
- Set the matching `DSPY_WITH_*` environment variables (see `Gemfile`) to include or exclude each sibling gem when running Bundler locally (for example `DSPY_WITH_GEPA=1` or `DSPY_WITH_O11Y_LANGFUSE=1`). Refer to `docs/core-concepts/dependency-tree.md` for the full dependency map and roadmap.
100
+ | `dspy-schema` | Exposes `DSPy::TypeSystem::SorbetJsonSchema` for downstream reuse. (Still required by the core `dspy` gem; extraction lets other projects depend on it directly.) | **Stable** (v1.0.0) |
101
+ | `dspy-code_act` | Think-Code-Observe agents that synthesize and execute Ruby safely. (Add the gem or set `DSPY_WITH_CODE_ACT=1` before requiring `dspy/code_act`.) | **Stable** (v1.0.0) |
102
+ | `dspy-datasets` | Dataset helpers plus Parquet/Polars tooling for richer evaluation corpora. (Toggle via `DSPY_WITH_DATASETS`.) | **Stable** (v1.0.0) |
103
+ | `dspy-evals` | High-throughput evaluation harness with metrics, callbacks, and regression fixtures. (Toggle via `DSPY_WITH_EVALS`.) | **Stable** (v1.0.0) |
104
+ | `dspy-miprov2` | Bayesian optimization + Gaussian Process backend for the MIPROv2 teleprompter. (Install or export `DSPY_WITH_MIPROV2=1` before requiring the teleprompter.) | **Stable** (v1.0.0) |
105
+ | `dspy-gepa` | `DSPy::Teleprompt::GEPA`, reflection loops, experiment tracking, telemetry adapters. (Install or set `DSPY_WITH_GEPA=1`.) | **Stable** (v1.0.0) |
106
+ | `gepa` | GEPA optimizer core (Pareto engine, telemetry, reflective proposer). | **Stable** (v1.0.0) |
107
+ | `dspy-o11y` | Core observability APIs: `DSPy::Observability`, async span processor, observation types. (Install or set `DSPY_WITH_O11Y=1`.) | **Stable** (v1.0.0) |
108
+ | `dspy-o11y-langfuse` | Auto-configures DSPy observability to stream spans to Langfuse via OTLP. (Install or set `DSPY_WITH_O11Y_LANGFUSE=1`.) | **Stable** (v1.0.0) |
109
+
110
+ Set the matching `DSPY_WITH_*` environment variables (see `Gemfile`) to include or exclude each sibling gem when running Bundler locally (for example `DSPY_WITH_GEPA=1` or `DSPY_WITH_O11Y_LANGFUSE=1`). Refer to `adr/013-dependency-tree.md` for the full dependency map and roadmap.
67
111
  ### Your First Reliable Predictor
68
112
 
69
113
  ```ruby
@@ -259,15 +259,34 @@ module DSPy
259
259
  # Executes method with around callbacks
260
260
  def execute_with_around_callbacks(method_name, original_method, *args, **kwargs, &block)
261
261
  callbacks = self.class.send(:callbacks_for, method_name)[:around]
262
+ args_copy = args.dup
263
+ kwargs_copy = kwargs.dup
262
264
 
263
265
  # Build callback chain from innermost (original method) to outermost
264
266
  chain = callbacks.reverse.inject(
265
267
  -> { original_method.bind(self).call(*args, **kwargs, &block) }
266
268
  ) do |inner, callback|
267
269
  if callback.is_a?(Proc)
268
- -> { instance_exec(-> { inner.call }, &callback) }
270
+ -> do
271
+ next_proc = -> { inner.call }
272
+ proc_arity = callback.arity
273
+ expects_extra = proc_arity.abs > 1
274
+
275
+ if expects_extra
276
+ instance_exec(next_proc, args_copy, kwargs_copy, &callback)
277
+ else
278
+ instance_exec(next_proc, &callback)
279
+ end
280
+ end
269
281
  else
270
- -> { send(callback) { inner.call } }
282
+ -> do
283
+ method_obj = method(callback)
284
+ if method_obj.arity.zero?
285
+ send(callback) { inner.call }
286
+ else
287
+ send(callback, args_copy, kwargs_copy) { inner.call }
288
+ end
289
+ end
271
290
  end
272
291
  end
273
292
 
data/lib/dspy/context.rb CHANGED
@@ -33,7 +33,8 @@ module DSPy
33
33
  context = {
34
34
  trace_id: SecureRandom.uuid,
35
35
  span_stack: [],
36
- otel_span_stack: []
36
+ otel_span_stack: [],
37
+ module_stack: []
37
38
  }
38
39
 
39
40
  # Set in both Thread and Fiber storage
@@ -158,6 +159,48 @@ module DSPy
158
159
  end
159
160
  end
160
161
  end
162
+
163
+ def with_module(module_instance, label: nil)
164
+ stack = module_stack
165
+ entry = build_module_entry(module_instance, label)
166
+ stack.push(entry)
167
+ yield
168
+ ensure
169
+ if stack.last.equal?(entry)
170
+ stack.pop
171
+ else
172
+ stack.delete(entry)
173
+ end
174
+ end
175
+
176
+ def module_stack
177
+ current[:module_stack] ||= []
178
+ end
179
+
180
+ def module_context_attributes
181
+ stack = module_stack
182
+ return {} if stack.empty?
183
+
184
+ path = stack.map do |entry|
185
+ {
186
+ id: entry[:id],
187
+ class: entry[:class],
188
+ label: entry[:label]
189
+ }
190
+ end
191
+
192
+ ancestry_token = path.map { |node| node[:id] }.join('>')
193
+
194
+ {
195
+ module_path: path,
196
+ module_root: path.first,
197
+ module_leaf: path.last,
198
+ module_scope: {
199
+ ancestry_token: ancestry_token,
200
+ depth: path.length
201
+ }
202
+ }
203
+ end
161
204
 
162
205
  def clear!
163
206
  # Clear both the thread-specific key and the legacy key
@@ -218,6 +261,14 @@ module DSPy
218
261
  end
219
262
  end
220
263
  end
264
+
265
+ def build_module_entry(module_instance, explicit_label)
266
+ {
267
+ id: (module_instance.respond_to?(:module_scope_id) ? module_instance.module_scope_id : SecureRandom.uuid),
268
+ class: module_instance.class.name,
269
+ label: explicit_label || (module_instance.respond_to?(:module_scope_label) ? module_instance.module_scope_label : nil)
270
+ }
271
+ end
221
272
  end
222
273
  end
223
274
  end
data/lib/dspy/module.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
+ require 'securerandom'
5
6
  require_relative 'context'
6
7
  require_relative 'callbacks'
7
8
 
@@ -12,10 +13,84 @@ module DSPy
12
13
  include Dry::Configurable
13
14
  include DSPy::Callbacks
14
15
 
16
+ class SubcriptionScope < T::Enum
17
+ enums do
18
+ Descendants = new('descendants')
19
+ SelfOnly = new('self')
20
+ end
21
+ end
22
+
23
+ DEFAULT_MODULE_SUBSCRIPTION_SCOPE = SubcriptionScope::Descendants
24
+
25
+ module ForwardOverrideHooks
26
+ def method_added(method_name)
27
+ super
28
+
29
+ return unless method_name == :forward
30
+ return if self == DSPy::Module
31
+ return if @_wrapping_forward
32
+
33
+ @_wrapping_forward = true
34
+
35
+ original = instance_method(:forward)
36
+ define_method(:forward) do |*args, **kwargs, &block|
37
+ instrument_forward_call(args, kwargs) do
38
+ original.bind(self).call(*args, **kwargs, &block)
39
+ end
40
+ end
41
+ ensure
42
+ @_wrapping_forward = false
43
+ end
44
+ end
45
+
46
+ class << self
47
+ def inherited(subclass)
48
+ super
49
+ specs_copy = module_subscription_specs.map(&:dup)
50
+ subclass.instance_variable_set(:@module_subscription_specs, specs_copy)
51
+ subclass.extend(ForwardOverrideHooks)
52
+ end
53
+
54
+ def subscribe(pattern, handler = nil, scope: DEFAULT_MODULE_SUBSCRIPTION_SCOPE, &block)
55
+ scope = normalize_scope(scope)
56
+ raise ArgumentError, 'Provide a handler method or block' if handler.nil? && block.nil?
57
+
58
+ module_subscription_specs << {
59
+ pattern: pattern,
60
+ handler: handler,
61
+ block: block,
62
+ scope: scope
63
+ }
64
+ end
65
+
66
+ def module_subscription_specs
67
+ @module_subscription_specs ||= []
68
+ end
69
+
70
+ private
71
+
72
+ def validate_subscription_scope!(scope)
73
+ T.must(scope)
74
+ end
75
+
76
+ def normalize_scope(scope)
77
+ return scope if scope.is_a?(SubcriptionScope)
78
+
79
+ case scope
80
+ when :descendants
81
+ SubcriptionScope::Descendants
82
+ when :self
83
+ SubcriptionScope::SelfOnly
84
+ else
85
+ raise ArgumentError, "Unsupported subscription scope: #{scope.inspect}"
86
+ end
87
+ end
88
+ end
89
+
15
90
  # Per-instance LM configuration
16
91
  setting :lm, default: nil
17
92
 
18
- # Define callback hooks for forward method
93
+ # Enable callback hooks for forward method
19
94
  create_before_callback :forward
20
95
  create_after_callback :forward
21
96
  create_around_callback :forward
@@ -29,23 +104,8 @@ module DSPy
29
104
  .returns(T.type_parameter(:O))
30
105
  end
31
106
  def forward(**input_values)
32
- # Create span for this module's execution
33
- observation_type = DSPy::ObservationType.for_module_class(self.class)
34
- DSPy::Context.with_span(
35
- operation: "#{self.class.name}.forward",
36
- **observation_type.langfuse_attributes,
37
- 'langfuse.observation.input' => input_values.to_json,
38
- 'dspy.module' => self.class.name
39
- ) do |span|
107
+ instrument_forward_call([], input_values) do
40
108
  result = forward_untyped(**input_values)
41
-
42
- # Add output to span
43
- if span && result
44
- output_json = result.respond_to?(:to_h) ? result.to_h.to_json : result.to_json rescue result.to_s
45
- span.set_attribute('langfuse.observation.output', output_json)
46
- end
47
-
48
- # Cast the result of forward_untyped to the expected output type
49
109
  T.cast(result, T.type_parameter(:O))
50
110
  end
51
111
  end
@@ -116,5 +176,141 @@ module DSPy
116
176
  def predictors
117
177
  named_predictors.map { |(_, predictor)| predictor }
118
178
  end
179
+
180
+ def instrument_forward_call(call_args, call_kwargs)
181
+ ensure_module_subscriptions!
182
+
183
+ DSPy::Context.with_module(self) do
184
+ observation_type = DSPy::ObservationType.for_module_class(self.class)
185
+ span_attributes = observation_type.langfuse_attributes.merge(
186
+ 'langfuse.observation.input' => serialize_module_input(call_args, call_kwargs),
187
+ 'dspy.module' => self.class.name
188
+ )
189
+
190
+ DSPy::Context.with_span(
191
+ operation: "#{self.class.name}.forward",
192
+ **span_attributes
193
+ ) do |span|
194
+ yield.tap do |result|
195
+ if span && result
196
+ span.set_attribute('langfuse.observation.output', serialize_module_output(result))
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ def serialize_module_input(call_args, call_kwargs)
204
+ payload = if call_kwargs && !call_kwargs.empty?
205
+ call_kwargs
206
+ elsif call_args && !call_args.empty?
207
+ call_args
208
+ else
209
+ {}
210
+ end
211
+
212
+ payload.to_json
213
+ rescue StandardError
214
+ payload.to_s
215
+ end
216
+
217
+ def serialize_module_output(result)
218
+ if result.respond_to?(:to_h)
219
+ result.to_h.to_json
220
+ else
221
+ result.to_json
222
+ end
223
+ rescue StandardError
224
+ result.to_s
225
+ end
226
+
227
+ private :instrument_forward_call, :serialize_module_input, :serialize_module_output
228
+
229
+ sig { returns(String) }
230
+ def module_scope_id
231
+ @module_scope_id ||= SecureRandom.uuid
232
+ end
233
+
234
+ sig { returns(T.nilable(String)) }
235
+ def module_scope_label
236
+ @module_scope_label
237
+ end
238
+
239
+ sig { params(label: T.nilable(String)).void }
240
+ def module_scope_label=(label)
241
+ @module_scope_label = label
242
+ end
243
+
244
+ sig { returns(T::Array[String]) }
245
+ def registered_module_subscriptions
246
+ Array(@module_subscription_ids).dup
247
+ end
248
+
249
+ sig { void }
250
+ def unsubscribe_module_events
251
+ Array(@module_subscription_ids).each { |id| DSPy.events.unsubscribe(id) }
252
+ @module_subscription_ids = []
253
+ @module_subscriptions_registered = false
254
+ end
255
+
256
+ private
257
+
258
+ def ensure_module_subscriptions!
259
+ return if @module_subscriptions_registered
260
+
261
+ specs = self.class.module_subscription_specs
262
+ if specs.empty?
263
+ @module_subscriptions_registered = true
264
+ return
265
+ end
266
+
267
+ @module_subscription_ids ||= []
268
+ specs.each do |spec|
269
+ callback = build_subscription_callback(spec)
270
+ subscription_id = DSPy.events.subscribe(spec[:pattern], &callback)
271
+ @module_subscription_ids << subscription_id
272
+ end
273
+
274
+ @module_subscriptions_registered = true
275
+ end
276
+
277
+ def build_subscription_callback(spec)
278
+ scope = spec[:scope] || DEFAULT_MODULE_SUBSCRIPTION_SCOPE
279
+ handler = spec[:handler]
280
+ block = spec[:block]
281
+
282
+ proc do |event_name, attributes|
283
+ next unless module_event_within_scope?(attributes, scope)
284
+
285
+ if handler
286
+ send(handler, event_name, attributes)
287
+ else
288
+ instance_exec(event_name, attributes, &block)
289
+ end
290
+ end
291
+ end
292
+
293
+ def module_event_within_scope?(attributes, scope)
294
+ metadata = extract_module_metadata(attributes)
295
+ return false unless metadata
296
+
297
+ case scope
298
+ when SubcriptionScope::SelfOnly
299
+ metadata[:leaf_id] == module_scope_id
300
+ else
301
+ metadata[:path_ids].include?(module_scope_id)
302
+ end
303
+ end
304
+
305
+ def extract_module_metadata(attributes)
306
+ path = attributes[:module_path] || attributes['module_path']
307
+ leaf = attributes[:module_leaf] || attributes['module_leaf']
308
+ return nil unless path.is_a?(Array)
309
+
310
+ {
311
+ path_ids: path.map { |entry| entry[:id] || entry['id'] }.compact,
312
+ leaf_id: leaf&.dig(:id) || leaf&.dig('id')
313
+ }
314
+ end
119
315
  end
120
316
  end
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.30.0"
4
+ VERSION = "0.30.1"
5
5
  end
data/lib/dspy.rb CHANGED
@@ -75,6 +75,10 @@ module DSPy
75
75
  create_event_span(event_name, attributes)
76
76
  end
77
77
 
78
+ attributes = attributes.dup
79
+ module_metadata = DSPy::Context.module_context_attributes
80
+ attributes.merge!(module_metadata) unless module_metadata.empty?
81
+
78
82
  # Perform the actual logging (original DSPy.log behavior)
79
83
  # emit_log(event_name, attributes)
80
84
 
@@ -100,6 +104,8 @@ module DSPy
100
104
  # Merge context automatically (but don't include span_stack)
101
105
  context = Context.current.dup
102
106
  context.delete(:span_stack)
107
+ context.delete(:otel_span_stack)
108
+ context.delete(:module_stack)
103
109
  attributes = context.merge(attributes)
104
110
  attributes[:event] = event_name
105
111
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dspy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.0
4
+ version: 0.30.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vicente Reig Rincón de Arellano
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-10-25 00:00:00.000000000 Z
11
+ date: 2025-10-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: dry-configurable