dspy 0.34.2 → 0.34.3

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/lib/dspy/chain_of_thought.rb +3 -2
  3. data/lib/dspy/context.rb +17 -1
  4. data/lib/dspy/evals/version.rb +1 -1
  5. data/lib/dspy/evals.rb +42 -31
  6. data/lib/dspy/events.rb +2 -3
  7. data/lib/dspy/example.rb +1 -1
  8. data/lib/dspy/lm/adapter.rb +39 -0
  9. data/lib/dspy/lm/json_strategy.rb +37 -2
  10. data/lib/dspy/lm/message.rb +1 -1
  11. data/lib/dspy/lm/response.rb +1 -1
  12. data/lib/dspy/lm/usage.rb +4 -4
  13. data/lib/dspy/lm.rb +9 -49
  14. data/lib/dspy/mixins/type_coercion.rb +189 -30
  15. data/lib/dspy/module.rb +70 -25
  16. data/lib/dspy/predict.rb +32 -5
  17. data/lib/dspy/prediction.rb +15 -57
  18. data/lib/dspy/prompt.rb +50 -30
  19. data/lib/dspy/propose/dataset_summary_generator.rb +1 -1
  20. data/lib/dspy/propose/grounded_proposer.rb +3 -3
  21. data/lib/dspy/re_act.rb +0 -162
  22. data/lib/dspy/registry/signature_registry.rb +3 -3
  23. data/lib/dspy/ruby_llm/lm/adapters/ruby_llm_adapter.rb +1 -27
  24. data/lib/dspy/schema/sorbet_json_schema.rb +7 -6
  25. data/lib/dspy/schema/version.rb +1 -1
  26. data/lib/dspy/schema_adapters.rb +1 -1
  27. data/lib/dspy/storage/program_storage.rb +2 -2
  28. data/lib/dspy/structured_outputs_prompt.rb +3 -3
  29. data/lib/dspy/teleprompt/utils.rb +2 -2
  30. data/lib/dspy/tools/github_cli_toolset.rb +7 -7
  31. data/lib/dspy/tools/text_processing_toolset.rb +2 -2
  32. data/lib/dspy/tools/toolset.rb +1 -1
  33. data/lib/dspy/version.rb +1 -1
  34. data/lib/dspy.rb +1 -4
  35. metadata +1 -26
  36. data/lib/dspy/events/subscriber_mixin.rb +0 -79
  37. data/lib/dspy/events/subscribers.rb +0 -43
  38. data/lib/dspy/memory/embedding_engine.rb +0 -68
  39. data/lib/dspy/memory/in_memory_store.rb +0 -216
  40. data/lib/dspy/memory/local_embedding_engine.rb +0 -244
  41. data/lib/dspy/memory/memory_compactor.rb +0 -298
  42. data/lib/dspy/memory/memory_manager.rb +0 -266
  43. data/lib/dspy/memory/memory_record.rb +0 -163
  44. data/lib/dspy/memory/memory_store.rb +0 -90
  45. data/lib/dspy/memory.rb +0 -30
  46. data/lib/dspy/tools/memory_toolset.rb +0 -117
@@ -9,6 +9,62 @@ module DSPy
9
9
  module TypeCoercion
10
10
  extend T::Sig
11
11
 
12
+ # Centralized enum deserialization with case-insensitive fallback.
13
+ # Uses try_deserialize for O(1) exact match, then a lazily-built
14
+ # case-insensitive lookup hash as fallback for LLM casing variations.
15
+ #
16
+ # Returns the enum instance on match, or nil if no match found.
17
+ sig { params(enum_class: T.untyped, value: T.untyped).returns(T.nilable(T::Enum)) }
18
+ def self.deserialize_enum(enum_class, value)
19
+ return value if value.is_a?(enum_class)
20
+
21
+ str = value.to_s
22
+ result = enum_class.try_deserialize(str)
23
+ return result if result
24
+
25
+ @ci_enum_cache ||= {}
26
+ ci_map = (@ci_enum_cache[enum_class] ||=
27
+ enum_class.values.each_with_object({}) { |v, h| h[v.serialize.downcase.freeze] = v }.freeze)
28
+
29
+ ci_map[str.downcase]
30
+ end
31
+
32
+ # Module-level enum type detection (delegates to instance method)
33
+ sig { params(type: T.untyped).returns(T::Boolean) }
34
+ def self.enum_type?(type)
35
+ return false unless type
36
+
37
+ case type
38
+ when Class
39
+ !!(type < T::Enum)
40
+ when T::Types::Simple
41
+ type.raw_type.is_a?(Class) && !!(type.raw_type < T::Enum)
42
+ when T::Types::Union
43
+ non_nil = type.types.reject { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
44
+ non_nil.size == 1 && enum_type?(non_nil.first)
45
+ else
46
+ false
47
+ end
48
+ rescue StandardError
49
+ false
50
+ end
51
+
52
+ # Module-level enum class extraction (delegates to instance method)
53
+ sig { params(prop_type: T.untyped).returns(T.class_of(T::Enum)) }
54
+ def self.extract_enum_class(prop_type)
55
+ case prop_type
56
+ when Class
57
+ return prop_type if prop_type < T::Enum
58
+ when T::Types::Simple
59
+ return prop_type.raw_type if prop_type.raw_type.is_a?(Class) && prop_type.raw_type < T::Enum
60
+ when T::Types::Union
61
+ non_nil = prop_type.types.reject { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
62
+ return extract_enum_class(non_nil.first) if non_nil.size == 1
63
+ end
64
+
65
+ raise ArgumentError, "Not an enum type: #{prop_type.inspect}"
66
+ end
67
+
12
68
  private
13
69
 
14
70
  # Coerces output attributes to match their expected types
@@ -57,32 +113,16 @@ module DSPy
57
113
  end
58
114
  end
59
115
 
60
- # Checks if a type is an enum type
116
+ # Checks if a type is an enum type (handles Class, Simple, and nilable unions)
61
117
  sig { params(type: T.untyped).returns(T::Boolean) }
62
118
  def enum_type?(type)
63
- return false unless type
64
-
65
- if type.is_a?(Class)
66
- !!(type < T::Enum)
67
- elsif type.is_a?(T::Types::Simple)
68
- !!(type.raw_type < T::Enum)
69
- else
70
- false
71
- end
72
- rescue StandardError
73
- false
119
+ DSPy::Mixins::TypeCoercion.enum_type?(type)
74
120
  end
75
121
 
76
- # Extracts the enum class from a type
122
+ # Extracts the enum class from a type (handles Class, Simple, and nilable unions)
77
123
  sig { params(prop_type: T.untyped).returns(T.class_of(T::Enum)) }
78
124
  def extract_enum_class(prop_type)
79
- if prop_type.is_a?(Class) && prop_type < T::Enum
80
- prop_type
81
- elsif prop_type.is_a?(T::Types::Simple) && prop_type.raw_type < T::Enum
82
- prop_type.raw_type
83
- else
84
- T.cast(prop_type, T.class_of(T::Enum))
85
- end
125
+ DSPy::Mixins::TypeCoercion.extract_enum_class(prop_type)
86
126
  end
87
127
 
88
128
  # Checks if a type matches a simple type (like Float, Integer)
@@ -130,6 +170,106 @@ module DSPy
130
170
  type.is_a?(T::Types::Union) && type.types.any? { |t| t == T::Utils.coerce(NilClass) }
131
171
  end
132
172
 
173
+ # Checks if a type is a scalar (primitives that don't need special serialization)
174
+ sig { params(type_object: T.untyped).returns(T::Boolean) }
175
+ def scalar_type?(type_object)
176
+ case type_object
177
+ when T::Types::Simple
178
+ scalar_classes = [String, Integer, Float, Numeric, TrueClass, FalseClass]
179
+ scalar_classes.any? { |klass| type_object.raw_type == klass || type_object.raw_type <= klass }
180
+ when T::Types::Union
181
+ # Union is scalar if all its types are scalars
182
+ type_object.types.all? { |t| scalar_type?(t) }
183
+ else
184
+ false
185
+ end
186
+ end
187
+
188
+ # Checks if a type is structured (arrays, hashes, structs that need type preservation)
189
+ sig { params(type_object: T.untyped).returns(T::Boolean) }
190
+ def structured_type?(type_object)
191
+ return true if type_object.is_a?(T::Types::TypedArray)
192
+ return true if type_object.is_a?(T::Types::TypedHash)
193
+
194
+ if type_object.is_a?(T::Types::Simple)
195
+ raw_type = type_object.raw_type
196
+ return true if raw_type.respond_to?(:<=) && raw_type <= T::Struct
197
+ end
198
+
199
+ # For union types (like T.nilable(T::Array[...])), check if any non-nil type is structured
200
+ if type_object.is_a?(T::Types::Union)
201
+ non_nil_types = type_object.types.reject { |t| t.is_a?(T::Types::Simple) && t.raw_type == NilClass }
202
+ return non_nil_types.any? { |t| structured_type?(t) }
203
+ end
204
+
205
+ false
206
+ end
207
+
208
+ # Checks if a type is String or compatible with String (e.g., T.any(String, ...) or T.nilable(String))
209
+ sig { params(type_object: T.untyped).returns(T::Boolean) }
210
+ def string_type?(type_object)
211
+ case type_object
212
+ when T::Types::Simple
213
+ type_object.raw_type == String
214
+ when T::Types::Union
215
+ # Check if any of the union types is String
216
+ type_object.types.any? { |t| t.is_a?(T::Types::Simple) && t.raw_type == String }
217
+ else
218
+ false
219
+ end
220
+ end
221
+
222
+ # Get a readable type name from a Sorbet type object
223
+ sig { params(type_object: T.untyped).returns(String) }
224
+ def type_name(type_object)
225
+ case type_object
226
+ when T::Types::TypedArray
227
+ element_type = type_object.type
228
+ "T::Array[#{type_name(element_type)}]"
229
+ when T::Types::TypedHash
230
+ "T::Hash"
231
+ when T::Types::Simple
232
+ type_object.raw_type.to_s
233
+ when T::Types::Union
234
+ types_str = type_object.types.map { |t| type_name(t) }.join(', ')
235
+ "T.any(#{types_str})"
236
+ else
237
+ type_object.to_s
238
+ end
239
+ end
240
+
241
+ # Returns an appropriate default value for a given Sorbet type
242
+ # This is used when max iterations is reached without a successful completion
243
+ sig { params(type_object: T.untyped).returns(T.untyped) }
244
+ def default_value_for_type(type_object)
245
+ # Handle TypedArray (T::Array[...])
246
+ return [] if type_object.is_a?(T::Types::TypedArray)
247
+
248
+ # Handle TypedHash (T::Hash[...])
249
+ return {} if type_object.is_a?(T::Types::TypedHash)
250
+
251
+ # Handle simple types
252
+ case type_object
253
+ when T::Types::Simple
254
+ raw_type = type_object.raw_type
255
+ case raw_type.to_s
256
+ when 'String' then ''
257
+ when 'Integer' then 0
258
+ when 'Float' then 0.0
259
+ when 'TrueClass', 'FalseClass' then false
260
+ else
261
+ # For T::Struct types, return nil as fallback
262
+ nil
263
+ end
264
+ when T::Types::Union
265
+ # For unions, return nil (assuming it's nilable) or first non-nil default
266
+ nil
267
+ else
268
+ # Default fallback for unknown types
269
+ nil
270
+ end
271
+ end
272
+
133
273
  # Coerces an array value, converting each element as needed
134
274
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
135
275
  def coerce_array_value(value, prop_type)
@@ -152,7 +292,7 @@ module DSPy
152
292
  # Convert string keys to enum instances if key_type is an enum
153
293
  result = if enum_type?(key_type)
154
294
  enum_class = extract_enum_class(key_type)
155
- value.transform_keys { |k| enum_class.deserialize(k.to_s) }
295
+ value.transform_keys { |k| DSPy::Mixins::TypeCoercion.deserialize_enum(enum_class, k) || k }
156
296
  else
157
297
  # For non-enum keys, coerce them to the expected type
158
298
  value.transform_keys { |k| coerce_value_to_type(k, key_type) }
@@ -197,7 +337,19 @@ module DSPy
197
337
  [key, val]
198
338
  end
199
339
  end.to_h
200
-
340
+
341
+ # Strip nil values for non-nilable fields that have defaults.
342
+ # LLMs in advisory mode may return null for unused fields.
343
+ # Removing the key lets Sorbet use the field's default value.
344
+ coerced_hash.reject! do |key, val|
345
+ next false unless val.nil?
346
+ prop_info = struct_props[key]
347
+ next false unless prop_info
348
+ prop_type = prop_info[:type_object] || prop_info[:type]
349
+ has_default = prop_info.key?(:default) || prop_info[:fully_optional]
350
+ !is_nilable_type?(prop_type) && has_default
351
+ end
352
+
201
353
  # Create the struct instance
202
354
  struct_class.new(**coerced_hash)
203
355
  rescue ArgumentError => e
@@ -209,6 +361,17 @@ module DSPy
209
361
  # Coerces a union value by using _type discriminator
210
362
  sig { params(value: T.untyped, union_type: T.untyped).returns(T.untyped) }
211
363
  def coerce_union_value(value, union_type)
364
+ # Anthropic tool use may return complex oneOf union fields as JSON strings
365
+ # instead of nested objects. Parse them back into Hashes for coercion.
366
+ if value.is_a?(String)
367
+ begin
368
+ parsed = JSON.parse(value)
369
+ value = parsed if parsed.is_a?(Hash)
370
+ rescue JSON::ParserError
371
+ # Not JSON — fall through
372
+ end
373
+ end
374
+
212
375
  return value unless value.is_a?(Hash)
213
376
 
214
377
  # Check for _type discriminator field
@@ -311,14 +474,10 @@ module DSPy
311
474
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
312
475
  def coerce_enum_value(value, prop_type)
313
476
  enum_class = extract_enum_class(prop_type)
314
-
315
- # If value is already an instance of the enum class, return it as-is
316
- return value if value.is_a?(enum_class)
317
-
318
- # Otherwise, try to deserialize from string
319
- enum_class.deserialize(value.to_s)
320
- rescue ArgumentError, KeyError => e
321
- DSPy.logger.debug("Failed to coerce to enum #{enum_class}: #{e.message}")
477
+ result = DSPy::Mixins::TypeCoercion.deserialize_enum(enum_class, value)
478
+ return result if result
479
+
480
+ DSPy.logger.debug("Failed to coerce to enum #{enum_class}: no match for #{value.inspect}")
322
481
  value
323
482
  end
324
483
  end
data/lib/dspy/module.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
5
  require 'securerandom'
6
+ require 'weakref'
6
7
  require_relative 'context'
7
8
  require_relative 'callbacks'
8
9
  require_relative 'type_serializer'
@@ -24,15 +25,20 @@ module DSPy
24
25
 
25
26
  DEFAULT_MODULE_SUBSCRIPTION_SCOPE = SubcriptionScope::Descendants
26
27
 
28
+ # Hook to wrap forward methods with instrumentation.
29
+ # Uses a Set-based guard (not boolean) to prevent re-wrapping when
30
+ # other hooks (like Callbacks) also use define_method.
27
31
  module ForwardOverrideHooks
28
32
  def method_added(method_name)
29
33
  super
30
34
 
31
35
  return unless method_name == :forward
32
36
  return if self == DSPy::Module
33
- return if @_wrapping_forward
34
37
 
35
- @_wrapping_forward = true
38
+ # Use Set-based guard - persists across hook invocations
39
+ @_forward_instrumented ||= Set.new
40
+ return if @_forward_instrumented.include?(object_id)
41
+ @_forward_instrumented << object_id
36
42
 
37
43
  original = instance_method(:forward)
38
44
  define_method(:forward) do |*args, **kwargs, &block|
@@ -40,8 +46,6 @@ module DSPy
40
46
  original.bind(self).call(*args, **kwargs, &block)
41
47
  end
42
48
  end
43
- ensure
44
- @_wrapping_forward = false
45
49
  end
46
50
  end
47
51
 
@@ -71,6 +75,35 @@ module DSPy
71
75
 
72
76
  private
73
77
 
78
+ def build_subscription_callback(weakref, subscription_id_ref, spec)
79
+ scope = spec[:scope] || DEFAULT_MODULE_SUBSCRIPTION_SCOPE
80
+ handler = spec[:handler]
81
+ block = spec[:block]
82
+
83
+ ->(event_name, attributes) do
84
+ target = begin
85
+ weakref.__getobj__
86
+ rescue WeakRef::RefError
87
+ nil
88
+ end
89
+
90
+ unless target
91
+ subscription_id = subscription_id_ref[:id]
92
+ DSPy.events.unsubscribe(subscription_id) if subscription_id
93
+ DSPy.logger&.debug(event: 'module.subscription.auto_unsubscribe', subscription_id: subscription_id)
94
+ return
95
+ end
96
+
97
+ return unless target.send(:module_event_within_scope?, attributes, scope)
98
+
99
+ if handler
100
+ target.send(handler, event_name, attributes)
101
+ else
102
+ target.instance_exec(event_name, attributes, &block)
103
+ end
104
+ end
105
+ end
106
+
74
107
  def validate_subscription_scope!(scope)
75
108
  T.must(scope)
76
109
  end
@@ -97,7 +130,8 @@ module DSPy
97
130
  create_after_callback :forward
98
131
  create_around_callback :forward
99
132
 
100
- # The main forward method that users will call is generic and type parameterized
133
+ # The main forward method that users will call is generic and type parameterized.
134
+ # Instrument here only when subclasses don't override forward.
101
135
  sig do
102
136
  type_parameters(:I, :O)
103
137
  .params(
@@ -106,10 +140,14 @@ module DSPy
106
140
  .returns(T.type_parameter(:O))
107
141
  end
108
142
  def forward(**input_values)
109
- instrument_forward_call([], input_values) do
110
- result = forward_untyped(**input_values)
111
- T.cast(result, T.type_parameter(:O))
143
+ result = if self.class.instance_method(:forward).owner == DSPy::Module
144
+ instrument_forward_call([], input_values) do
145
+ forward_untyped(**input_values)
146
+ end
147
+ else
148
+ forward_untyped(**input_values)
112
149
  end
150
+ T.cast(result, T.type_parameter(:O))
113
151
  end
114
152
 
115
153
  # The implementation method that subclasses must override
@@ -294,8 +332,28 @@ module DSPy
294
332
  @module_subscriptions_registered = false
295
333
  end
296
334
 
335
+ sig { returns(T.self_type) }
336
+ def dup_for_thread
337
+ cloned = dup
338
+ cloned.instance_variable_set(:@module_subscription_ids, [])
339
+ cloned.instance_variable_set(:@module_subscriptions_registered, false)
340
+ cloned.instance_variable_set(:@module_scope_id, SecureRandom.uuid)
341
+ cloned.send(:reset_thread_state)
342
+ cloned
343
+ end
344
+
297
345
  private
298
346
 
347
+ def reset_thread_state
348
+ instance_variables.each do |ivar|
349
+ value = instance_variable_get(ivar)
350
+ case value
351
+ when Array, Hash, Set
352
+ instance_variable_set(ivar, value.dup)
353
+ end
354
+ end
355
+ end
356
+
299
357
  # Propagate LM configuration to child predictors recursively
300
358
  # Skips children that already have an explicit LM configured
301
359
  sig { params(lm: T.untyped).void }
@@ -322,30 +380,17 @@ module DSPy
322
380
 
323
381
  @module_subscription_ids ||= []
324
382
  specs.each do |spec|
325
- callback = build_subscription_callback(spec)
383
+ weakref = WeakRef.new(self)
384
+ subscription_id_ref = { id: nil }
385
+ callback = self.class.send(:build_subscription_callback, weakref, subscription_id_ref, spec)
326
386
  subscription_id = DSPy.events.subscribe(spec[:pattern], &callback)
387
+ subscription_id_ref[:id] = subscription_id
327
388
  @module_subscription_ids << subscription_id
328
389
  end
329
390
 
330
391
  @module_subscriptions_registered = true
331
392
  end
332
393
 
333
- def build_subscription_callback(spec)
334
- scope = spec[:scope] || DEFAULT_MODULE_SUBSCRIPTION_SCOPE
335
- handler = spec[:handler]
336
- block = spec[:block]
337
-
338
- proc do |event_name, attributes|
339
- next unless module_event_within_scope?(attributes, scope)
340
-
341
- if handler
342
- send(handler, event_name, attributes)
343
- else
344
- instance_exec(event_name, attributes, &block)
345
- end
346
- end
347
- end
348
-
349
394
  def module_event_within_scope?(attributes, scope)
350
395
  metadata = extract_module_metadata(attributes)
351
396
  return false unless metadata
data/lib/dspy/predict.rb CHANGED
@@ -64,8 +64,7 @@ module DSPy
64
64
  super()
65
65
  @signature_class = signature_class
66
66
 
67
- # Prompt will read schema_format from config automatically
68
- @prompt = Prompt.from_signature(signature_class)
67
+ @prompt = build_prompt_from_signature
69
68
  @demos = nil
70
69
  end
71
70
 
@@ -146,6 +145,13 @@ module DSPy
146
145
  instance
147
146
  end
148
147
 
148
+ sig { override.params(block: T.proc.params(config: T.untyped).void).returns(T.self_type) }
149
+ def configure(&block)
150
+ super(&block)
151
+ sync_prompt_formats_from_lm(config.lm) if config.lm
152
+ self
153
+ end
154
+
149
155
  sig { override.returns(T::Array[[String, DSPy::Module]]) }
150
156
  def named_predictors
151
157
  [["self", self]]
@@ -166,9 +172,6 @@ module DSPy
166
172
  input_props = @signature_class.input_struct_class.props
167
173
  coerced_input_values = coerce_output_attributes(input_values, input_props)
168
174
 
169
- # Store coerced input values for optimization
170
- @last_input_values = coerced_input_values.clone
171
-
172
175
  # Validate input with coerced values
173
176
  validate_input_struct(coerced_input_values)
174
177
 
@@ -190,6 +193,30 @@ module DSPy
190
193
 
191
194
  private
192
195
 
196
+ def reset_thread_state
197
+ super
198
+ end
199
+
200
+ def build_prompt_from_signature
201
+ lm_source = lm
202
+ schema_format = lm_source&.schema_format
203
+ data_format = lm_source&.respond_to?(:data_format) ? lm_source.data_format : nil
204
+
205
+ Prompt.from_signature(@signature_class, schema_format: schema_format, data_format: data_format)
206
+ end
207
+
208
+ def sync_prompt_formats_from_lm(lm_source)
209
+ return unless lm_source
210
+
211
+ schema_format = lm_source&.schema_format
212
+ data_format = lm_source&.respond_to?(:data_format) ? lm_source.data_format : nil
213
+
214
+ prompt = @prompt
215
+ prompt = prompt.with_schema_format(schema_format) if schema_format
216
+ prompt = prompt.with_data_format(data_format) if data_format
217
+ @prompt = prompt
218
+ end
219
+
193
220
  # Validates input using signature struct (assumes input is already coerced)
194
221
  sig { params(input_values: T::Hash[Symbol, T.untyped]).void }
195
222
  def validate_input_struct(input_values)
@@ -122,9 +122,10 @@ module DSPy
122
122
  converted[key] = nil
123
123
  end
124
124
  elsif is_enum_type?(prop_type) && value.is_a?(String)
125
- # Convert string to enum
125
+ # Convert string to enum (case-insensitive for structured_outputs: false)
126
126
  enum_class = extract_enum_class(prop_type)
127
- converted[key] = enum_class.deserialize(value)
127
+ result = DSPy::Mixins::TypeCoercion.deserialize_enum(enum_class, value)
128
+ converted[key] = result || value
128
129
  elsif value.is_a?(Hash) && needs_struct_conversion?(prop_type)
129
130
  # Regular struct field that needs conversion
130
131
  converted[key] = convert_to_struct(value, prop_type)
@@ -188,60 +189,12 @@ module DSPy
188
189
 
189
190
  sig { params(type: T.untyped).returns(T::Boolean) }
190
191
  def is_enum_type?(type)
191
- return false if type.nil?
192
-
193
- case type
194
- when T::Types::Simple
195
- # Handle regular enum types
196
- begin
197
- raw_type = type.raw_type
198
- return false unless raw_type.is_a?(Class)
199
- result = raw_type < T::Enum
200
- return result == true # Force conversion to boolean
201
- rescue StandardError
202
- return false
203
- end
204
- when T::Private::Types::SimplePairUnion, T::Types::Union
205
- # Handle T.nilable enum types
206
- # Find the non-nil type and check if it's an enum
207
- non_nil_types = if type.respond_to?(:types)
208
- type.types.reject { |t| t.respond_to?(:raw_type) && t.raw_type == NilClass }
209
- else
210
- []
211
- end
212
-
213
- # For nilable types, we expect exactly one non-nil type
214
- return false unless non_nil_types.size == 1
215
-
216
- non_nil_type = non_nil_types.first
217
- return is_enum_type?(non_nil_type) # Recursively check
218
- else
219
- return false
220
- end
192
+ DSPy::Mixins::TypeCoercion.enum_type?(type)
221
193
  end
222
194
 
223
195
  sig { params(type: T.untyped).returns(T.untyped) }
224
196
  def extract_enum_class(type)
225
- case type
226
- when T::Types::Simple
227
- # Regular enum type
228
- type.raw_type
229
- when T::Private::Types::SimplePairUnion, T::Types::Union
230
- # Nilable enum type - find the non-nil type
231
- non_nil_types = if type.respond_to?(:types)
232
- type.types.reject { |t| t.respond_to?(:raw_type) && t.raw_type == NilClass }
233
- else
234
- []
235
- end
236
-
237
- if non_nil_types.size == 1
238
- extract_enum_class(non_nil_types.first)
239
- else
240
- raise ArgumentError, "Unable to extract enum class from complex union type: #{type.inspect}"
241
- end
242
- else
243
- raise ArgumentError, "Not an enum type: #{type.inspect}"
244
- end
197
+ DSPy::Mixins::TypeCoercion.extract_enum_class(type)
245
198
  end
246
199
 
247
200
  sig { params(union_type: T::Types::Union, discriminator_type: T.untyped).returns(T::Hash[String, T.untyped]) }
@@ -387,8 +340,10 @@ module DSPy
387
340
  if prop_info
388
341
  prop_type = prop_info[:type_object] || prop_info[:type]
389
342
  if v.is_a?(String) && is_enum_type?(prop_type)
390
- # Convert string to enum
391
- converted_hash[k] = prop_type.raw_type.deserialize(v)
343
+ # Convert string to enum (case-insensitive for structured_outputs: false)
344
+ enum_class = extract_enum_class(prop_type)
345
+ result = DSPy::Mixins::TypeCoercion.deserialize_enum(enum_class, v)
346
+ converted_hash[k] = result || v
392
347
  elsif v.is_a?(Hash) && needs_struct_conversion?(prop_type)
393
348
  converted_hash[k] = convert_to_struct(v, prop_type)
394
349
  elsif v.is_a?(Array) && needs_array_conversion?(prop_type)
@@ -488,8 +443,9 @@ module DSPy
488
443
  convert_to_struct(element, element_type)
489
444
  end
490
445
  elsif element.is_a?(String) && is_enum_type?(element_type)
491
- # Convert string to enum
492
- element_type.raw_type.deserialize(element)
446
+ # Convert string to enum (case-insensitive for structured_outputs: false)
447
+ enum_class = extract_enum_class(element_type)
448
+ DSPy::Mixins::TypeCoercion.deserialize_enum(enum_class, element) || element
493
449
  else
494
450
  element
495
451
  end
@@ -539,7 +495,9 @@ module DSPy
539
495
  if prop_info
540
496
  prop_type = prop_info[:type_object] || prop_info[:type]
541
497
  if v.is_a?(String) && is_enum_type?(prop_type)
542
- converted_hash[k] = prop_type.raw_type.deserialize(v)
498
+ enum_class = extract_enum_class(prop_type)
499
+ result = DSPy::Mixins::TypeCoercion.deserialize_enum(enum_class, v)
500
+ converted_hash[k] = result || v
543
501
  elsif v.is_a?(Hash) && needs_struct_conversion?(prop_type)
544
502
  converted_hash[k] = convert_to_struct(v, prop_type)
545
503
  elsif v.is_a?(Array) && needs_array_conversion?(prop_type)