dspy 0.3.1 → 0.4.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.
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+ require_relative 'signature'
5
+
6
+ module DSPy
7
+ # Represents a typed training/evaluation example with Signature validation
8
+ # Provides early validation and type safety for evaluation workflows
9
+ class Example
10
+ extend T::Sig
11
+
12
+ sig { returns(T.class_of(Signature)) }
13
+ attr_reader :signature_class
14
+
15
+ sig { returns(T::Struct) }
16
+ attr_reader :input
17
+
18
+ sig { returns(T::Struct) }
19
+ attr_reader :expected
20
+
21
+ sig { returns(T.nilable(String)) }
22
+ attr_reader :id
23
+
24
+ sig { returns(T.nilable(T::Hash[Symbol, T.untyped])) }
25
+ attr_reader :metadata
26
+
27
+ sig do
28
+ params(
29
+ signature_class: T.class_of(Signature),
30
+ input: T::Hash[Symbol, T.untyped],
31
+ expected: T::Hash[Symbol, T.untyped],
32
+ id: T.nilable(String),
33
+ metadata: T.nilable(T::Hash[Symbol, T.untyped])
34
+ ).void
35
+ end
36
+ def initialize(signature_class:, input:, expected:, id: nil, metadata: nil)
37
+ @signature_class = signature_class
38
+ @id = id
39
+ @metadata = metadata&.freeze
40
+
41
+ # Validate and create input struct
42
+ begin
43
+ @input = signature_class.input_struct_class.new(**input)
44
+ rescue ArgumentError => e
45
+ raise ArgumentError, "Invalid input for #{signature_class.name}: #{e.message}"
46
+ rescue TypeError => e
47
+ raise TypeError, "Type error in input for #{signature_class.name}: #{e.message}"
48
+ end
49
+
50
+ # Validate and create expected output struct
51
+ begin
52
+ @expected = signature_class.output_struct_class.new(**expected)
53
+ rescue ArgumentError => e
54
+ raise ArgumentError, "Invalid expected output for #{signature_class.name}: #{e.message}"
55
+ rescue TypeError => e
56
+ raise TypeError, "Type error in expected output for #{signature_class.name}: #{e.message}"
57
+ end
58
+ end
59
+
60
+ # Convert input struct to hash for program execution
61
+ sig { returns(T::Hash[Symbol, T.untyped]) }
62
+ def input_values
63
+ input_hash = {}
64
+ @input.class.props.keys.each do |key|
65
+ input_hash[key] = @input.send(key)
66
+ end
67
+ input_hash
68
+ end
69
+
70
+ # Convert expected struct to hash for comparison
71
+ sig { returns(T::Hash[Symbol, T.untyped]) }
72
+ def expected_values
73
+ expected_hash = {}
74
+ @expected.class.props.keys.each do |key|
75
+ expected_hash[key] = @expected.send(key)
76
+ end
77
+ expected_hash
78
+ end
79
+
80
+ # Check if prediction matches expected output using struct comparison
81
+ sig { params(prediction: T.untyped).returns(T::Boolean) }
82
+ def matches_prediction?(prediction)
83
+ return false unless prediction
84
+
85
+ # Compare each expected field with prediction
86
+ @expected.class.props.keys.all? do |key|
87
+ expected_value = @expected.send(key)
88
+
89
+ # Extract prediction value
90
+ prediction_value = case prediction
91
+ when T::Struct
92
+ prediction.respond_to?(key) ? prediction.send(key) : nil
93
+ when Hash
94
+ prediction[key] || prediction[key.to_s]
95
+ else
96
+ prediction.respond_to?(key) ? prediction.send(key) : nil
97
+ end
98
+
99
+ expected_value == prediction_value
100
+ end
101
+ end
102
+
103
+ # Serialization for persistence and debugging
104
+ sig { returns(T::Hash[Symbol, T.untyped]) }
105
+ def to_h
106
+ result = {
107
+ signature_class: @signature_class.name,
108
+ input: input_values,
109
+ expected: expected_values
110
+ }
111
+
112
+ result[:id] = @id if @id
113
+ result[:metadata] = @metadata if @metadata
114
+ result
115
+ end
116
+
117
+ # Create Example from hash representation
118
+ sig do
119
+ params(
120
+ hash: T::Hash[Symbol, T.untyped],
121
+ signature_registry: T.nilable(T::Hash[String, T.class_of(Signature)])
122
+ ).returns(Example)
123
+ end
124
+ def self.from_h(hash, signature_registry: nil)
125
+ signature_class_name = hash[:signature_class]
126
+
127
+ # Resolve signature class
128
+ signature_class = if signature_registry && signature_registry[signature_class_name]
129
+ signature_registry[signature_class_name]
130
+ else
131
+ # Try to resolve from constant
132
+ Object.const_get(signature_class_name)
133
+ end
134
+
135
+ new(
136
+ signature_class: signature_class,
137
+ input: hash[:input] || {},
138
+ expected: hash[:expected] || {},
139
+ id: hash[:id],
140
+ metadata: hash[:metadata]
141
+ )
142
+ end
143
+
144
+
145
+ # Batch validation for multiple examples
146
+ sig do
147
+ params(
148
+ signature_class: T.class_of(Signature),
149
+ examples_data: T::Array[T::Hash[Symbol, T.untyped]]
150
+ ).returns(T::Array[Example])
151
+ end
152
+ def self.validate_batch(signature_class, examples_data)
153
+ errors = []
154
+ examples = []
155
+
156
+ examples_data.each_with_index do |example_data, index|
157
+ begin
158
+ # Only support structured format with :input and :expected keys
159
+ unless example_data.key?(:input) && example_data.key?(:expected)
160
+ raise ArgumentError, "Example must have :input and :expected keys. Legacy flat format is no longer supported."
161
+ end
162
+
163
+ example = new(
164
+ signature_class: signature_class,
165
+ input: example_data[:input],
166
+ expected: example_data[:expected],
167
+ id: example_data[:id] || "example_#{index}"
168
+ )
169
+ examples << example
170
+ rescue => e
171
+ errors << "Example #{index}: #{e.message}"
172
+ end
173
+ end
174
+
175
+ unless errors.empty?
176
+ raise ArgumentError, "Validation errors:\n#{errors.join("\n")}"
177
+ end
178
+
179
+ examples
180
+ end
181
+
182
+ # Equality comparison
183
+ sig { params(other: T.untyped).returns(T::Boolean) }
184
+ def ==(other)
185
+ return false unless other.is_a?(Example)
186
+
187
+ @signature_class == other.signature_class &&
188
+ input_values == other.input_values &&
189
+ expected_values == other.expected_values
190
+ end
191
+
192
+ # String representation for debugging
193
+ sig { returns(String) }
194
+ def to_s
195
+ "DSPy::Example(#{@signature_class.name}) input=#{input_values} expected=#{expected_values}"
196
+ end
197
+
198
+ sig { returns(String) }
199
+ def inspect
200
+ to_s
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ class FewShotExample
7
+ extend T::Sig
8
+
9
+ sig { returns(T::Hash[Symbol, T.untyped]) }
10
+ attr_reader :input
11
+
12
+ sig { returns(T::Hash[Symbol, T.untyped]) }
13
+ attr_reader :output
14
+
15
+ sig { returns(T.nilable(String)) }
16
+ attr_reader :reasoning
17
+
18
+ sig do
19
+ params(
20
+ input: T::Hash[Symbol, T.untyped],
21
+ output: T::Hash[Symbol, T.untyped],
22
+ reasoning: T.nilable(String)
23
+ ).void
24
+ end
25
+ def initialize(input:, output:, reasoning: nil)
26
+ @input = input.freeze
27
+ @output = output.freeze
28
+ @reasoning = reasoning
29
+ end
30
+
31
+ sig { returns(String) }
32
+ def to_prompt_section
33
+ sections = []
34
+
35
+ sections << "## Input"
36
+ sections << "```json"
37
+ sections << JSON.pretty_generate(@input)
38
+ sections << "```"
39
+
40
+ if @reasoning
41
+ sections << "## Reasoning"
42
+ sections << @reasoning
43
+ end
44
+
45
+ sections << "## Output"
46
+ sections << "```json"
47
+ sections << JSON.pretty_generate(@output)
48
+ sections << "```"
49
+
50
+ sections.join("\n")
51
+ end
52
+
53
+ sig { returns(T::Hash[Symbol, T.untyped]) }
54
+ def to_h
55
+ result = {
56
+ input: @input,
57
+ output: @output
58
+ }
59
+ result[:reasoning] = @reasoning if @reasoning
60
+ result
61
+ end
62
+
63
+ sig { params(hash: T::Hash[Symbol, T.untyped]).returns(FewShotExample) }
64
+ def self.from_h(hash)
65
+ new(
66
+ input: hash[:input] || {},
67
+ output: hash[:output] || {},
68
+ reasoning: hash[:reasoning]
69
+ )
70
+ end
71
+
72
+ sig { params(other: T.untyped).returns(T::Boolean) }
73
+ def ==(other)
74
+ return false unless other.is_a?(FewShotExample)
75
+
76
+ @input == other.input &&
77
+ @output == other.output &&
78
+ @reasoning == other.reasoning
79
+ end
80
+ end
81
+ end
@@ -2,17 +2,34 @@
2
2
 
3
3
  require 'dry-monitor'
4
4
  require 'dry-configurable'
5
+ require 'time'
5
6
 
6
7
  module DSPy
7
8
  # Core instrumentation module using dry-monitor for event emission
8
- # Provides extension points for logging, Langfuse, New Relic, and custom monitoring
9
+ # Provides extension points for logging, OpenTelemetry, New Relic, Langfuse, and custom monitoring
9
10
  module Instrumentation
10
- # Get the current logger subscriber instance (lazy initialization)
11
+ # Get a logger subscriber instance (creates new instance each time)
11
12
  def self.logger_subscriber
12
- @logger_subscriber ||= begin
13
- require_relative 'subscribers/logger_subscriber'
14
- DSPy::Subscribers::LoggerSubscriber.new
15
- end
13
+ require_relative 'subscribers/logger_subscriber'
14
+ DSPy::Subscribers::LoggerSubscriber.new
15
+ end
16
+
17
+ # Get an OpenTelemetry subscriber instance (creates new instance each time)
18
+ def self.otel_subscriber
19
+ require_relative 'subscribers/otel_subscriber'
20
+ DSPy::Subscribers::OtelSubscriber.new
21
+ end
22
+
23
+ # Get a New Relic subscriber instance (creates new instance each time)
24
+ def self.newrelic_subscriber
25
+ require_relative 'subscribers/newrelic_subscriber'
26
+ DSPy::Subscribers::NewrelicSubscriber.new
27
+ end
28
+
29
+ # Get a Langfuse subscriber instance (creates new instance each time)
30
+ def self.langfuse_subscriber
31
+ require_relative 'subscribers/langfuse_subscriber'
32
+ DSPy::Subscribers::LangfuseSubscriber.new
16
33
  end
17
34
 
18
35
  def self.notifications
@@ -29,6 +46,55 @@ module DSPy
29
46
  n.register_event('dspy.react.tool_call')
30
47
  n.register_event('dspy.react.iteration_complete')
31
48
  n.register_event('dspy.react.max_iterations')
49
+
50
+ # Evaluation events
51
+ n.register_event('dspy.evaluation.start')
52
+ n.register_event('dspy.evaluation.example')
53
+ n.register_event('dspy.evaluation.batch')
54
+ n.register_event('dspy.evaluation.batch_complete')
55
+
56
+ # Optimization events
57
+ n.register_event('dspy.optimization.start')
58
+ n.register_event('dspy.optimization.complete')
59
+ n.register_event('dspy.optimization.trial_start')
60
+ n.register_event('dspy.optimization.trial_complete')
61
+ n.register_event('dspy.optimization.bootstrap_start')
62
+ n.register_event('dspy.optimization.bootstrap_complete')
63
+ n.register_event('dspy.optimization.bootstrap_example')
64
+ n.register_event('dspy.optimization.minibatch_evaluation')
65
+ n.register_event('dspy.optimization.instruction_proposal_start')
66
+ n.register_event('dspy.optimization.instruction_proposal_complete')
67
+ n.register_event('dspy.optimization.error')
68
+ n.register_event('dspy.optimization.save')
69
+ n.register_event('dspy.optimization.load')
70
+
71
+ # Storage events
72
+ n.register_event('dspy.storage.save_start')
73
+ n.register_event('dspy.storage.save_complete')
74
+ n.register_event('dspy.storage.save_error')
75
+ n.register_event('dspy.storage.load_start')
76
+ n.register_event('dspy.storage.load_complete')
77
+ n.register_event('dspy.storage.load_error')
78
+ n.register_event('dspy.storage.delete')
79
+ n.register_event('dspy.storage.export')
80
+ n.register_event('dspy.storage.import')
81
+ n.register_event('dspy.storage.cleanup')
82
+
83
+ # Registry events
84
+ n.register_event('dspy.registry.register_start')
85
+ n.register_event('dspy.registry.register_complete')
86
+ n.register_event('dspy.registry.register_error')
87
+ n.register_event('dspy.registry.deploy_start')
88
+ n.register_event('dspy.registry.deploy_complete')
89
+ n.register_event('dspy.registry.deploy_error')
90
+ n.register_event('dspy.registry.rollback_start')
91
+ n.register_event('dspy.registry.rollback_complete')
92
+ n.register_event('dspy.registry.rollback_error')
93
+ n.register_event('dspy.registry.performance_update')
94
+ n.register_event('dspy.registry.export')
95
+ n.register_event('dspy.registry.import')
96
+ n.register_event('dspy.registry.auto_deployment')
97
+ n.register_event('dspy.registry.automatic_rollback')
32
98
  end
33
99
  end
34
100
 
@@ -75,6 +141,9 @@ module DSPy
75
141
 
76
142
  # Emit event without timing (for discrete events)
77
143
  def self.emit(event_name, payload = {})
144
+ # Handle nil payload
145
+ payload ||= {}
146
+
78
147
  enhanced_payload = payload.merge(
79
148
  timestamp: Time.now.iso8601,
80
149
  status: payload[:status] || 'success'
@@ -101,13 +170,33 @@ module DSPy
101
170
  end
102
171
 
103
172
  def self.emit_event(event_name, payload)
104
- # Ensure logger subscriber is initialized
105
- logger_subscriber
173
+ # Only emit events - subscribers self-register when explicitly created
106
174
  notifications.instrument(event_name, payload)
107
175
  end
108
176
 
109
177
  def self.setup_subscribers
110
178
  # Lazy initialization - will be created when first accessed
179
+ # Force initialization of enabled subscribers
180
+ logger_subscriber
181
+
182
+ # Only initialize if dependencies are available
183
+ begin
184
+ otel_subscriber if ENV['OTEL_EXPORTER_OTLP_ENDPOINT'] || defined?(OpenTelemetry)
185
+ rescue LoadError
186
+ # OpenTelemetry not available, skip
187
+ end
188
+
189
+ begin
190
+ newrelic_subscriber if defined?(NewRelic)
191
+ rescue LoadError
192
+ # New Relic not available, skip
193
+ end
194
+
195
+ begin
196
+ langfuse_subscriber if ENV['LANGFUSE_SECRET_KEY'] || defined?(Langfuse)
197
+ rescue LoadError
198
+ # Langfuse not available, skip
199
+ end
111
200
  end
112
201
  end
113
202
  end
@@ -7,8 +7,7 @@ module DSPy
7
7
  # Maps provider prefixes to adapter classes
8
8
  ADAPTER_MAP = {
9
9
  'openai' => 'OpenAIAdapter',
10
- 'anthropic' => 'AnthropicAdapter',
11
- 'ruby_llm' => 'RubyLLMAdapter'
10
+ 'anthropic' => 'AnthropicAdapter'
12
11
  }.freeze
13
12
 
14
13
  class << self
@@ -27,13 +26,12 @@ module DSPy
27
26
 
28
27
  # Parse model_id to determine provider and model
29
28
  def parse_model_id(model_id)
30
- if model_id.include?('/')
31
- provider, model = model_id.split('/', 2)
32
- [provider, model]
33
- else
34
- # Legacy format: assume ruby_llm for backward compatibility
35
- ['ruby_llm', model_id]
29
+ unless model_id.include?('/')
30
+ raise ArgumentError, "model_id must include provider (e.g., 'openai/gpt-4', 'anthropic/claude-3'). Legacy format without provider is no longer supported."
36
31
  end
32
+
33
+ provider, model = model_id.split('/', 2)
34
+ [provider, model]
37
35
  end
38
36
 
39
37
  def get_adapter_class(provider)
data/lib/dspy/lm.rb CHANGED
@@ -13,7 +13,6 @@ require_relative 'instrumentation/token_tracker'
13
13
  # Load adapters
14
14
  require_relative 'lm/adapters/openai_adapter'
15
15
  require_relative 'lm/adapters/anthropic_adapter'
16
- require_relative 'lm/adapters/ruby_llm_adapter'
17
16
 
18
17
  module DSPy
19
18
  class LM
@@ -80,13 +79,12 @@ module DSPy
80
79
  private
81
80
 
82
81
  def parse_model_id(model_id)
83
- if model_id.include?('/')
84
- provider, model = model_id.split('/', 2)
85
- [provider, model]
86
- else
87
- # Legacy format: assume ruby_llm for backward compatibility
88
- ['ruby_llm', model_id]
82
+ unless model_id.include?('/')
83
+ raise ArgumentError, "model_id must include provider (e.g., 'openai/gpt-4', 'anthropic/claude-3'). Legacy format without provider is no longer supported."
89
84
  end
85
+
86
+ provider, model = model_id.split('/', 2)
87
+ [provider, model]
90
88
  end
91
89
 
92
90
  def build_messages(inference_module, input_values)
data/lib/dspy/predict.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'sorbet-runtime'
4
4
  require_relative 'module'
5
5
  require_relative 'instrumentation'
6
+ require_relative 'prompt'
6
7
 
7
8
  module DSPy
8
9
  # Exception raised when prediction fails validation
@@ -25,52 +26,49 @@ module DSPy
25
26
  sig { returns(T.class_of(Signature)) }
26
27
  attr_reader :signature_class
27
28
 
29
+ sig { returns(Prompt) }
30
+ attr_reader :prompt
31
+
28
32
  sig { params(signature_class: T.class_of(Signature)).void }
29
33
  def initialize(signature_class)
30
34
  super()
31
35
  @signature_class = signature_class
36
+ @prompt = Prompt.from_signature(signature_class)
32
37
  end
33
38
 
39
+ # Backward compatibility methods - delegate to prompt object
34
40
  sig { returns(String) }
35
41
  def system_signature
36
- <<-PROMPT
37
- Your input schema fields are:
38
- ```json
39
- #{JSON.generate(@signature_class.input_json_schema)}
40
- ```
41
- Your output schema fields are:
42
- ```json
43
- #{JSON.generate(@signature_class.output_json_schema)}
44
- ````
45
-
46
- All interactions will be structured in the following way, with the appropriate values filled in.
47
-
48
- ## Input values
49
- ```json
50
- {input_values}
51
- ```
52
- ## Output values
53
- Respond exclusively with the output schema fields in the json block below.
54
- ```json
55
- {output_values}
56
- ```
57
-
58
- In adhering to this structure, your objective is: #{@signature_class.description}
59
-
60
- PROMPT
42
+ @prompt.render_system_prompt
61
43
  end
62
44
 
63
45
  sig { params(input_values: T::Hash[Symbol, T.untyped]).returns(String) }
64
46
  def user_signature(input_values)
65
- <<-PROMPT
66
- ## Input Values
67
- ```json
68
- #{JSON.generate(input_values)}
69
- ```
70
-
71
- Respond with the corresponding output schema fields wrapped in a ```json ``` block,
72
- starting with the heading `## Output values`.
73
- PROMPT
47
+ @prompt.render_user_prompt(input_values)
48
+ end
49
+
50
+ # New prompt-based interface for optimization
51
+ sig { params(new_prompt: Prompt).returns(Predict) }
52
+ def with_prompt(new_prompt)
53
+ # Create a new instance with the same signature but updated prompt
54
+ instance = self.class.new(@signature_class)
55
+ instance.instance_variable_set(:@prompt, new_prompt)
56
+ instance
57
+ end
58
+
59
+ sig { params(instruction: String).returns(Predict) }
60
+ def with_instruction(instruction)
61
+ with_prompt(@prompt.with_instruction(instruction))
62
+ end
63
+
64
+ sig { params(examples: T::Array[FewShotExample]).returns(Predict) }
65
+ def with_examples(examples)
66
+ with_prompt(@prompt.with_examples(examples))
67
+ end
68
+
69
+ sig { params(examples: T::Array[FewShotExample]).returns(Predict) }
70
+ def add_examples(examples)
71
+ with_prompt(@prompt.add_examples(examples))
74
72
  end
75
73
 
76
74
  sig { override.params(kwargs: T.untyped).returns(T.type_parameter(:O)) }