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,560 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+ require_relative '../instrumentation'
5
+ require_relative '../signature'
6
+ require_relative '../predict'
7
+
8
+ module DSPy
9
+ module Propose
10
+ # Grounded Proposer for generating better instructions based on training data
11
+ # Analyzes task patterns and creates contextually appropriate instructions
12
+ class GroundedProposer
13
+ extend T::Sig
14
+
15
+ # Configuration for instruction proposal
16
+ class Config
17
+ extend T::Sig
18
+
19
+ sig { returns(Integer) }
20
+ attr_accessor :num_instruction_candidates
21
+
22
+ sig { returns(Integer) }
23
+ attr_accessor :max_examples_for_analysis
24
+
25
+ sig { returns(Integer) }
26
+ attr_accessor :max_instruction_length
27
+
28
+ sig { returns(T::Boolean) }
29
+ attr_accessor :use_task_description
30
+
31
+ sig { returns(T::Boolean) }
32
+ attr_accessor :use_input_output_analysis
33
+
34
+ sig { returns(T::Boolean) }
35
+ attr_accessor :use_few_shot_examples
36
+
37
+ sig { returns(String) }
38
+ attr_accessor :proposal_model
39
+
40
+ sig { void }
41
+ def initialize
42
+ @num_instruction_candidates = 5
43
+ @max_examples_for_analysis = 10
44
+ @max_instruction_length = 200
45
+ @use_task_description = true
46
+ @use_input_output_analysis = true
47
+ @use_few_shot_examples = true
48
+ @proposal_model = "gpt-4o-mini"
49
+ end
50
+ end
51
+
52
+ # Result of instruction proposal
53
+ class ProposalResult
54
+ extend T::Sig
55
+
56
+ sig { returns(T::Array[String]) }
57
+ attr_reader :candidate_instructions
58
+
59
+ sig { returns(T::Hash[Symbol, T.untyped]) }
60
+ attr_reader :analysis
61
+
62
+ sig { returns(T::Hash[Symbol, T.untyped]) }
63
+ attr_reader :metadata
64
+
65
+ sig do
66
+ params(
67
+ candidate_instructions: T::Array[String],
68
+ analysis: T::Hash[Symbol, T.untyped],
69
+ metadata: T::Hash[Symbol, T.untyped]
70
+ ).void
71
+ end
72
+ def initialize(candidate_instructions:, analysis:, metadata:)
73
+ @candidate_instructions = candidate_instructions.freeze
74
+ @analysis = analysis.freeze
75
+ @metadata = metadata.freeze
76
+ end
77
+
78
+ sig { returns(String) }
79
+ def best_instruction
80
+ @candidate_instructions.first || ""
81
+ end
82
+
83
+ sig { returns(Integer) }
84
+ def num_candidates
85
+ @candidate_instructions.size
86
+ end
87
+ end
88
+
89
+ sig { returns(Config) }
90
+ attr_reader :config
91
+
92
+ sig { params(config: T.nilable(Config)).void }
93
+ def initialize(config: nil)
94
+ @config = config || Config.new
95
+ end
96
+
97
+ # Generate instruction candidates for a signature and training examples
98
+ sig do
99
+ params(
100
+ signature_class: T.class_of(DSPy::Signature),
101
+ examples: T::Array[T.untyped],
102
+ few_shot_examples: T.nilable(T::Array[T.untyped]),
103
+ current_instruction: T.nilable(String)
104
+ ).returns(ProposalResult)
105
+ end
106
+ def propose_instructions(signature_class, examples, few_shot_examples: nil, current_instruction: nil)
107
+ Instrumentation.instrument('dspy.optimization.instruction_proposal_start', {
108
+ signature_class: signature_class.name,
109
+ num_examples: examples.size,
110
+ has_few_shot: !few_shot_examples.nil?,
111
+ has_current_instruction: !current_instruction.nil?
112
+ }) do
113
+ # Analyze the task and training data
114
+ analysis = analyze_task(signature_class, examples, few_shot_examples)
115
+
116
+ # Generate instruction candidates
117
+ candidates = generate_instruction_candidates(
118
+ signature_class,
119
+ analysis,
120
+ current_instruction
121
+ )
122
+
123
+ # Filter and rank candidates
124
+ filtered_candidates = filter_and_rank_candidates(candidates, analysis)
125
+
126
+ metadata = {
127
+ generation_timestamp: Time.now.iso8601,
128
+ model_used: @config.proposal_model,
129
+ num_examples_analyzed: [examples.size, @config.max_examples_for_analysis].min,
130
+ original_instruction: current_instruction
131
+ }
132
+
133
+ result = ProposalResult.new(
134
+ candidate_instructions: filtered_candidates,
135
+ analysis: analysis,
136
+ metadata: metadata
137
+ )
138
+
139
+ emit_proposal_complete_event(result)
140
+ result
141
+ end
142
+ end
143
+
144
+ private
145
+
146
+ # Analyze the task based on signature and training examples
147
+ sig do
148
+ params(
149
+ signature_class: T.class_of(DSPy::Signature),
150
+ examples: T::Array[T.untyped],
151
+ few_shot_examples: T.nilable(T::Array[T.untyped])
152
+ ).returns(T::Hash[Symbol, T.untyped])
153
+ end
154
+ def analyze_task(signature_class, examples, few_shot_examples)
155
+ analysis = {
156
+ task_description: signature_class.description,
157
+ input_fields: extract_field_info(signature_class.input_struct_class),
158
+ output_fields: extract_field_info(signature_class.output_struct_class),
159
+ example_patterns: analyze_example_patterns(examples),
160
+ complexity_indicators: assess_task_complexity(signature_class, examples)
161
+ }
162
+
163
+ if few_shot_examples && few_shot_examples.any?
164
+ analysis[:few_shot_patterns] = analyze_few_shot_patterns(few_shot_examples)
165
+ end
166
+
167
+ analysis
168
+ end
169
+
170
+ # Extract field information from struct classes
171
+ sig { params(struct_class: T.class_of(T::Struct)).returns(T::Array[T::Hash[Symbol, T.untyped]]) }
172
+ def extract_field_info(struct_class)
173
+ struct_class.props.map do |name, prop_info|
174
+ {
175
+ name: name,
176
+ type: prop_info[:type].to_s,
177
+ description: prop_info[:description] || "",
178
+ required: !prop_info[:rules]&.any? { |rule| rule.is_a?(T::Props::NilableRules) }
179
+ }
180
+ end
181
+ end
182
+
183
+ # Analyze patterns in training examples
184
+ sig { params(examples: T::Array[T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
185
+ def analyze_example_patterns(examples)
186
+ analysis_examples = examples.take(@config.max_examples_for_analysis)
187
+
188
+ {
189
+ total_examples: examples.size,
190
+ analyzed_examples: analysis_examples.size,
191
+ input_patterns: analyze_input_patterns(analysis_examples),
192
+ output_patterns: analyze_output_patterns(analysis_examples),
193
+ common_themes: extract_common_themes(analysis_examples)
194
+ }
195
+ end
196
+
197
+ # Analyze input patterns in examples
198
+ sig { params(examples: T::Array[T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
199
+ def analyze_input_patterns(examples)
200
+ input_lengths = []
201
+ input_types = []
202
+ common_keywords = Hash.new(0)
203
+
204
+ examples.each do |example|
205
+ input_values = extract_input_values(example)
206
+
207
+ input_values.each do |key, value|
208
+ if value.is_a?(String)
209
+ input_lengths << value.length
210
+ # Extract potential keywords
211
+ value.downcase.split(/\W+/).each { |word| common_keywords[word] += 1 if word.length > 3 }
212
+ end
213
+ input_types << value.class.name
214
+ end
215
+ end
216
+
217
+ {
218
+ avg_input_length: input_lengths.empty? ? 0 : input_lengths.sum.to_f / input_lengths.size,
219
+ common_input_types: input_types.tally,
220
+ frequent_keywords: common_keywords.sort_by { |_, count| -count }.take(10).to_h
221
+ }
222
+ end
223
+
224
+ # Analyze output patterns in examples
225
+ sig { params(examples: T::Array[T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
226
+ def analyze_output_patterns(examples)
227
+ output_lengths = []
228
+ output_types = []
229
+
230
+ examples.each do |example|
231
+ expected_values = extract_expected_values(example)
232
+
233
+ expected_values.each do |key, value|
234
+ if value.is_a?(String)
235
+ output_lengths << value.length
236
+ end
237
+ output_types << value.class.name
238
+ end
239
+ end
240
+
241
+ {
242
+ avg_output_length: output_lengths.empty? ? 0 : output_lengths.sum.to_f / output_lengths.size,
243
+ common_output_types: output_types.tally
244
+ }
245
+ end
246
+
247
+ # Extract common themes from examples
248
+ sig { params(examples: T::Array[T.untyped]).returns(T::Array[String]) }
249
+ def extract_common_themes(examples)
250
+ themes = []
251
+
252
+ # Simple heuristics for theme detection
253
+ input_texts = examples.map { |ex| extract_input_values(ex).values.select { |v| v.is_a?(String) } }.flatten
254
+
255
+ if input_texts.any? { |text| text.downcase.include?("question") || text.include?("?") }
256
+ themes << "question_answering"
257
+ end
258
+
259
+ if input_texts.any? { |text| text.downcase.match?(/\b(classify|category|type)\b/) }
260
+ themes << "classification"
261
+ end
262
+
263
+ if input_texts.any? { |text| text.match?(/\d+.*[+\-*\/].*\d+/) }
264
+ themes << "mathematical_reasoning"
265
+ end
266
+
267
+ if input_texts.any? { |text| text.downcase.match?(/\b(analyze|explain|reason)\b/) }
268
+ themes << "analytical_reasoning"
269
+ end
270
+
271
+ themes
272
+ end
273
+
274
+ # Analyze few-shot example patterns
275
+ sig { params(few_shot_examples: T::Array[T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
276
+ def analyze_few_shot_patterns(few_shot_examples)
277
+ {
278
+ num_examples: few_shot_examples.size,
279
+ demonstrates_reasoning: few_shot_examples.any? { |ex| has_reasoning_field?(ex) },
280
+ example_variety: assess_example_variety(few_shot_examples)
281
+ }
282
+ end
283
+
284
+ # Assess task complexity indicators
285
+ sig do
286
+ params(
287
+ signature_class: T.class_of(DSPy::Signature),
288
+ examples: T::Array[T.untyped]
289
+ ).returns(T::Hash[Symbol, T.untyped])
290
+ end
291
+ def assess_task_complexity(signature_class, examples)
292
+ {
293
+ num_input_fields: signature_class.input_struct_class.props.size,
294
+ num_output_fields: signature_class.output_struct_class.props.size,
295
+ has_complex_outputs: has_complex_output_types?(signature_class),
296
+ requires_reasoning: task_requires_reasoning?(signature_class, examples)
297
+ }
298
+ end
299
+
300
+ # Generate instruction candidates using LLM
301
+ sig do
302
+ params(
303
+ signature_class: T.class_of(DSPy::Signature),
304
+ analysis: T::Hash[Symbol, T.untyped],
305
+ current_instruction: T.nilable(String)
306
+ ).returns(T::Array[String])
307
+ end
308
+ def generate_instruction_candidates(signature_class, analysis, current_instruction)
309
+ # Build context for instruction generation
310
+ context = build_generation_context(signature_class, analysis, current_instruction)
311
+
312
+ # Create instruction generation signature
313
+ instruction_signature = create_instruction_generation_signature
314
+
315
+ # Generate candidates using LLM
316
+ generator = DSPy::Predict.new(instruction_signature)
317
+
318
+ candidates = []
319
+ @config.num_instruction_candidates.times do |i|
320
+ begin
321
+ result = generator.call(
322
+ task_context: context,
323
+ requirements: build_requirements_text(analysis),
324
+ candidate_number: i + 1
325
+ )
326
+
327
+ instruction = result.instruction.strip
328
+
329
+ # Truncate if too long
330
+ if instruction.length > @config.max_instruction_length
331
+ instruction = instruction[0, @config.max_instruction_length].strip
332
+ # Try to end at a word boundary
333
+ if instruction.include?(' ')
334
+ instruction = instruction.rpartition(' ').first + '.'
335
+ end
336
+ end
337
+
338
+ candidates << instruction if instruction.length > 0
339
+ rescue => error
340
+ DSPy.logger.warn("Failed to generate instruction candidate #{i + 1}: #{error.message}")
341
+ end
342
+ end
343
+
344
+ # Ensure we have at least one candidate
345
+ if candidates.empty?
346
+ candidates << generate_fallback_instruction(signature_class, analysis)
347
+ end
348
+
349
+ candidates.uniq
350
+ end
351
+
352
+ # Build context for instruction generation
353
+ sig do
354
+ params(
355
+ signature_class: T.class_of(DSPy::Signature),
356
+ analysis: T::Hash[Symbol, T.untyped],
357
+ current_instruction: T.nilable(String)
358
+ ).returns(String)
359
+ end
360
+ def build_generation_context(signature_class, analysis, current_instruction)
361
+ context_parts = []
362
+
363
+ context_parts << "Task: #{signature_class.description}" if @config.use_task_description
364
+
365
+ if @config.use_input_output_analysis
366
+ context_parts << "Input fields: #{analysis[:input_fields].map { |f| "#{f[:name]} (#{f[:type]})" }.join(', ')}"
367
+ context_parts << "Output fields: #{analysis[:output_fields].map { |f| "#{f[:name]} (#{f[:type]})" }.join(', ')}"
368
+ end
369
+
370
+ if analysis[:common_themes] && analysis[:common_themes].any?
371
+ context_parts << "Task themes: #{analysis[:common_themes].join(', ')}"
372
+ end
373
+
374
+ if current_instruction
375
+ context_parts << "Current instruction: \"#{current_instruction}\""
376
+ end
377
+
378
+ context_parts.join("\n")
379
+ end
380
+
381
+ # Build requirements text for instruction generation
382
+ sig { params(analysis: T::Hash[Symbol, T.untyped]).returns(String) }
383
+ def build_requirements_text(analysis)
384
+ requirements = []
385
+
386
+ requirements << "Be specific and actionable"
387
+ requirements << "Guide the model's reasoning process"
388
+
389
+ if analysis[:complexity_indicators][:requires_reasoning]
390
+ requirements << "Encourage step-by-step thinking"
391
+ end
392
+
393
+ if analysis[:common_themes]&.include?("mathematical_reasoning")
394
+ requirements << "Emphasize mathematical accuracy"
395
+ end
396
+
397
+ if analysis[:common_themes]&.include?("classification")
398
+ requirements << "Encourage careful categorization"
399
+ end
400
+
401
+ requirements.join(". ") + "."
402
+ end
403
+
404
+ # Create signature for instruction generation
405
+ sig { returns(T.class_of(DSPy::Signature)) }
406
+ def create_instruction_generation_signature
407
+ Class.new(DSPy::Signature) do
408
+ description "Generate an improved instruction for a language model task"
409
+
410
+ input do
411
+ const :task_context, String, description: "Context about the task and current setup"
412
+ const :requirements, String, description: "Requirements for the instruction"
413
+ const :candidate_number, Integer, description: "Which candidate this is (for variety)"
414
+ end
415
+
416
+ output do
417
+ const :instruction, String, description: "A clear, specific instruction for the task"
418
+ end
419
+ end
420
+ end
421
+
422
+ # Generate a fallback instruction when LLM generation fails
423
+ sig do
424
+ params(
425
+ signature_class: T.class_of(DSPy::Signature),
426
+ analysis: T::Hash[Symbol, T.untyped]
427
+ ).returns(String)
428
+ end
429
+ def generate_fallback_instruction(signature_class, analysis)
430
+ base = signature_class.description || "Complete the given task"
431
+
432
+ if analysis[:complexity_indicators][:requires_reasoning]
433
+ "#{base} Think step by step and provide a clear explanation."
434
+ else
435
+ "#{base} Be accurate and specific in your response."
436
+ end
437
+ end
438
+
439
+ # Filter and rank instruction candidates
440
+ sig { params(candidates: T::Array[String], analysis: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
441
+ def filter_and_rank_candidates(candidates, analysis)
442
+ # Filter out duplicates and empty candidates
443
+ filtered = candidates.uniq.reject(&:empty?)
444
+
445
+ # Simple ranking based on length and content quality
446
+ filtered.sort_by do |instruction|
447
+ score = 0
448
+
449
+ # Prefer moderate length instructions
450
+ length_score = [instruction.length, @config.max_instruction_length].min / @config.max_instruction_length.to_f
451
+ score += length_score * 0.3
452
+
453
+ # Prefer instructions with action words
454
+ action_words = %w[analyze classify generate explain solve determine identify]
455
+ action_score = action_words.count { |word| instruction.downcase.include?(word) }
456
+ score += action_score * 0.4
457
+
458
+ # Prefer instructions that mention reasoning for complex tasks
459
+ if analysis[:complexity_indicators][:requires_reasoning]
460
+ reasoning_score = instruction.downcase.match?(/\b(step|think|reason|explain)\b/) ? 1 : 0
461
+ score += reasoning_score * 0.3
462
+ end
463
+
464
+ -score # Negative for descending sort
465
+ end
466
+ end
467
+
468
+ # Helper methods for extracting values from examples
469
+ sig { params(example: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
470
+ def extract_input_values(example)
471
+ case example
472
+ when DSPy::Example
473
+ example.input_values
474
+ when Hash
475
+ example[:input] || example.select { |k, _| k != :expected && k != :output }
476
+ else
477
+ example.respond_to?(:input) ? example.input : {}
478
+ end
479
+ end
480
+
481
+ sig { params(example: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
482
+ def extract_expected_values(example)
483
+ case example
484
+ when DSPy::Example
485
+ example.expected_values
486
+ when Hash
487
+ example[:expected] || example[:output] || {}
488
+ else
489
+ example.respond_to?(:expected) ? example.expected : {}
490
+ end
491
+ end
492
+
493
+ # Check if example has reasoning field
494
+ sig { params(example: T.untyped).returns(T::Boolean) }
495
+ def has_reasoning_field?(example)
496
+ values = extract_expected_values(example)
497
+ values.key?(:reasoning) || values.key?(:explanation) || values.key?(:rationale)
498
+ end
499
+
500
+ # Assess variety in examples
501
+ sig { params(examples: T::Array[T.untyped]).returns(String) }
502
+ def assess_example_variety(examples)
503
+ return "low" if examples.size < 3
504
+
505
+ # Simple heuristic based on input diversity
506
+ input_patterns = examples.map { |ex| extract_input_values(ex).values.map(&:to_s).join(" ") }
507
+ unique_patterns = input_patterns.uniq.size
508
+
509
+ variety_ratio = unique_patterns.to_f / examples.size
510
+
511
+ case variety_ratio
512
+ when 0.8..1.0 then "high"
513
+ when 0.5..0.8 then "medium"
514
+ else "low"
515
+ end
516
+ end
517
+
518
+ # Check if signature has complex output types
519
+ sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T::Boolean) }
520
+ def has_complex_output_types?(signature_class)
521
+ signature_class.output_struct_class.props.any? do |_, prop_info|
522
+ type_str = prop_info[:type].to_s
523
+ type_str.include?("Array") || type_str.include?("Hash") || type_str.include?("T::Enum")
524
+ end
525
+ end
526
+
527
+ # Check if task requires reasoning
528
+ sig { params(signature_class: T.class_of(DSPy::Signature), examples: T::Array[T.untyped]).returns(T::Boolean) }
529
+ def task_requires_reasoning?(signature_class, examples)
530
+ # Check if output has reasoning fields
531
+ has_reasoning_outputs = signature_class.output_struct_class.props.any? do |name, _|
532
+ name.to_s.match?(/reason|explain|rational|justif/i)
533
+ end
534
+
535
+ return true if has_reasoning_outputs
536
+
537
+ # Check if examples suggest reasoning is needed
538
+ sample_examples = examples.take(5)
539
+ requires_reasoning = sample_examples.any? do |example|
540
+ input_values = extract_input_values(example)
541
+ input_text = input_values.values.select { |v| v.is_a?(String) }.join(" ")
542
+ input_text.downcase.match?(/\b(why|how|explain|analyze|reason)\b/)
543
+ end
544
+
545
+ requires_reasoning
546
+ end
547
+
548
+ # Emit instruction proposal completion event
549
+ sig { params(result: ProposalResult).void }
550
+ def emit_proposal_complete_event(result)
551
+ Instrumentation.emit('dspy.optimization.instruction_proposal_complete', {
552
+ num_candidates: result.num_candidates,
553
+ best_instruction_length: result.best_instruction.length,
554
+ analysis_themes: result.analysis[:common_themes] || [],
555
+ model_used: @config.proposal_model
556
+ })
557
+ end
558
+ end
559
+ end
560
+ end