dspy 0.28.2 → 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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +2 -3
  3. data/lib/dspy/code_act.rb +14 -1
  4. data/lib/dspy/datasets/ade.rb +90 -0
  5. data/lib/dspy/datasets.rb +8 -0
  6. data/lib/dspy/lm.rb +4 -8
  7. data/lib/dspy/mixins/struct_builder.rb +17 -25
  8. data/lib/dspy/module.rb +12 -1
  9. data/lib/dspy/observability/async_span_processor.rb +67 -93
  10. data/lib/dspy/observability.rb +43 -1
  11. data/lib/dspy/predict.rb +10 -0
  12. data/lib/dspy/propose/dataset_summary_generator.rb +36 -3
  13. data/lib/dspy/propose/grounded_proposer.rb +118 -11
  14. data/lib/dspy/re_act.rb +13 -0
  15. data/lib/dspy/reflection_lm.rb +36 -0
  16. data/lib/dspy/teleprompt/gepa.rb +448 -2803
  17. data/lib/dspy/teleprompt/mipro_v2.rb +564 -65
  18. data/lib/dspy/teleprompt/utils.rb +8 -3
  19. data/lib/dspy/version.rb +2 -2
  20. data/lib/dspy.rb +3 -2
  21. data/lib/gepa/api.rb +61 -0
  22. data/lib/gepa/core/engine.rb +226 -0
  23. data/lib/gepa/core/evaluation_batch.rb +26 -0
  24. data/lib/gepa/core/result.rb +92 -0
  25. data/lib/gepa/core/state.rb +231 -0
  26. data/lib/gepa/logging/experiment_tracker.rb +54 -0
  27. data/lib/gepa/logging/logger.rb +57 -0
  28. data/lib/gepa/logging.rb +9 -0
  29. data/lib/gepa/proposer/base.rb +27 -0
  30. data/lib/gepa/proposer/merge_proposer.rb +424 -0
  31. data/lib/gepa/proposer/reflective_mutation/base.rb +48 -0
  32. data/lib/gepa/proposer/reflective_mutation/reflective_mutation.rb +188 -0
  33. data/lib/gepa/strategies/batch_sampler.rb +91 -0
  34. data/lib/gepa/strategies/candidate_selector.rb +97 -0
  35. data/lib/gepa/strategies/component_selector.rb +57 -0
  36. data/lib/gepa/strategies/instruction_proposal.rb +120 -0
  37. data/lib/gepa/telemetry.rb +122 -0
  38. data/lib/gepa/utils/pareto.rb +119 -0
  39. data/lib/gepa.rb +21 -0
  40. metadata +42 -4
  41. data/lib/dspy/teleprompt/simple_optimizer.rb +0 -503
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ require_relative '../base'
6
+ require_relative 'base'
7
+
8
+ module GEPA
9
+ module Proposer
10
+ class ReflectiveMutationProposer
11
+ extend T::Sig
12
+ include ProposeNewCandidate
13
+
14
+ sig do
15
+ params(
16
+ logger: T.untyped,
17
+ trainset: T::Array[T.untyped],
18
+ adapter: T.untyped,
19
+ candidate_selector: T.untyped,
20
+ module_selector: T.untyped,
21
+ batch_sampler: T.untyped,
22
+ perfect_score: Float,
23
+ skip_perfect_score: T::Boolean,
24
+ experiment_tracker: T.untyped,
25
+ reflection_lm: T.nilable(T.proc.params(prompt: String).returns(String)),
26
+ telemetry: T.nilable(T.untyped)
27
+ ).void
28
+ end
29
+ def initialize(
30
+ logger:,
31
+ trainset:,
32
+ adapter:,
33
+ candidate_selector:,
34
+ module_selector:,
35
+ batch_sampler:,
36
+ perfect_score:,
37
+ skip_perfect_score:,
38
+ experiment_tracker:,
39
+ reflection_lm: nil,
40
+ telemetry: nil
41
+ )
42
+ @logger = logger
43
+ @trainset = trainset
44
+ @adapter = adapter
45
+ @candidate_selector = candidate_selector
46
+ @module_selector = module_selector
47
+ @batch_sampler = batch_sampler
48
+ @perfect_score = perfect_score
49
+ @skip_perfect_score = skip_perfect_score
50
+ @experiment_tracker = experiment_tracker
51
+ @reflection_lm = reflection_lm
52
+ @telemetry = telemetry || GEPA::Telemetry
53
+ end
54
+
55
+ sig { override.params(state: GEPA::Core::State).returns(T.nilable(CandidateProposal)) }
56
+ def propose(state)
57
+ iteration = state.i + 1
58
+
59
+ with_span('gepa.proposer.reflective_mutation.propose', iteration: iteration) do
60
+ proposal_for_iteration(state, iteration)
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def proposal_for_iteration(state, iteration)
67
+ curr_prog_id = @candidate_selector.select_candidate_idx(state)
68
+ curr_prog = state.program_candidates[curr_prog_id]
69
+ ensure_trace_slot(state)
70
+ state.full_program_trace.last[:selected_program_candidate] = curr_prog_id
71
+
72
+ @logger.log("Iteration #{iteration}: Selected program #{curr_prog_id} score: #{state.per_program_tracked_scores[curr_prog_id]}")
73
+ @experiment_tracker.log_metrics({ iteration: iteration, selected_program_candidate: curr_prog_id }, step: iteration)
74
+
75
+ subsample_ids = @batch_sampler.next_minibatch_indices(@trainset.length, iteration - 1)
76
+ state.full_program_trace.last[:subsample_ids] = subsample_ids
77
+ minibatch = subsample_ids.map { |idx| @trainset[idx] }
78
+
79
+ eval_curr = with_span('gepa.proposer.evaluate_current', iteration: iteration) do
80
+ @adapter.evaluate(minibatch, curr_prog, capture_traces: true)
81
+ end
82
+
83
+ unless eval_curr.trajectories && !eval_curr.trajectories.empty?
84
+ @logger.log("Iteration #{iteration}: No trajectories captured. Skipping.")
85
+ return nil
86
+ end
87
+
88
+ state.total_num_evals += subsample_ids.length
89
+ state.full_program_trace.last[:subsample_scores] = eval_curr.scores
90
+
91
+ if @skip_perfect_score && eval_curr.scores.all? { |score| score >= @perfect_score }
92
+ @logger.log("Iteration #{iteration}: All subsample scores perfect. Skipping.")
93
+ return nil
94
+ end
95
+
96
+ @experiment_tracker.log_metrics({ subsample_score: eval_curr.scores.sum }, step: iteration)
97
+
98
+ predictor_names = @module_selector.select_modules(
99
+ state,
100
+ eval_curr.trajectories,
101
+ eval_curr.scores,
102
+ curr_prog_id,
103
+ curr_prog
104
+ )
105
+
106
+ reflective_dataset = nil
107
+ new_texts = nil
108
+
109
+ with_span('gepa.proposer.build_reflective_dataset', iteration: iteration) do
110
+ reflective_dataset = @adapter.make_reflective_dataset(curr_prog, eval_curr, predictor_names)
111
+ end
112
+
113
+ begin
114
+ new_texts = with_span('gepa.proposer.propose_texts', iteration: iteration) do
115
+ propose_new_texts(curr_prog, reflective_dataset, predictor_names)
116
+ end
117
+
118
+ new_texts.each do |name, text|
119
+ @logger.log("Iteration #{iteration}: Proposed new text for #{name}: #{text}")
120
+ end
121
+ @experiment_tracker.log_metrics(new_texts.transform_keys { |name| "new_instruction_#{name}" }, step: iteration)
122
+ rescue StandardError => e
123
+ @logger.log("Iteration #{iteration}: Exception during reflection/proposal: #{e}")
124
+ @logger.log(e.backtrace&.join("\n"))
125
+ return nil
126
+ end
127
+
128
+ new_candidate = curr_prog.dup
129
+ new_texts.each do |name, text|
130
+ raise ArgumentError, "Missing component #{name}" unless new_candidate.key?(name)
131
+ new_candidate[name] = text
132
+ end
133
+
134
+ eval_new = with_span('gepa.proposer.evaluate_new_candidate', iteration: iteration) do
135
+ @adapter.evaluate(minibatch, new_candidate, capture_traces: false)
136
+ end
137
+
138
+ state.total_num_evals += subsample_ids.length
139
+ state.full_program_trace.last[:new_subsample_scores] = eval_new.scores
140
+ @experiment_tracker.log_metrics({ new_subsample_score: eval_new.scores.sum }, step: iteration)
141
+
142
+ CandidateProposal.new(
143
+ candidate: new_candidate,
144
+ parent_program_ids: [curr_prog_id],
145
+ subsample_indices: subsample_ids,
146
+ subsample_scores_before: eval_curr.scores,
147
+ subsample_scores_after: eval_new.scores,
148
+ metadata: { iteration: iteration }
149
+ )
150
+ end
151
+
152
+ sig do
153
+ params(
154
+ candidate: T::Hash[String, String],
155
+ reflective_dataset: T::Hash[String, T::Array[T::Hash[String, T.untyped]]],
156
+ components_to_update: T::Array[String]
157
+ ).returns(T::Hash[String, String])
158
+ end
159
+ def propose_new_texts(candidate, reflective_dataset, components_to_update)
160
+ if @adapter.respond_to?(:propose_new_texts)
161
+ return @adapter.propose_new_texts(candidate, reflective_dataset, components_to_update)
162
+ end
163
+
164
+ raise ArgumentError, 'reflection_lm is required when adapter lacks propose_new_texts' unless @reflection_lm
165
+
166
+ components_to_update.each_with_object({}) do |name, acc|
167
+ signature_input = {
168
+ 'current_instruction_doc' => candidate[name],
169
+ 'dataset_with_feedback' => reflective_dataset.fetch(name)
170
+ }
171
+ acc[name] = GEPA::Strategies::InstructionProposalSignature.run(@reflection_lm, signature_input)['new_instruction']
172
+ end
173
+ end
174
+
175
+ sig { params(state: GEPA::Core::State).void }
176
+ def ensure_trace_slot(state)
177
+ state.full_program_trace << {} if state.full_program_trace.empty? || state.full_program_trace.last.nil?
178
+ end
179
+
180
+ sig do
181
+ params(operation: String, attrs: T::Hash[Symbol, T.untyped], block: T.proc.returns(T.untyped)).returns(T.untyped)
182
+ end
183
+ def with_span(operation, attrs = {}, &block)
184
+ @telemetry.with_span(operation, attrs, &block)
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module GEPA
6
+ module Strategies
7
+ class EpochShuffledBatchSampler
8
+ extend T::Sig
9
+
10
+ sig { params(minibatch_size: Integer, rng: T.nilable(Random), telemetry: T.nilable(T.untyped)).void }
11
+ def initialize(minibatch_size, rng: nil, telemetry: nil)
12
+ @minibatch_size = minibatch_size
13
+ @rng = rng || Random.new(0)
14
+ @telemetry = telemetry
15
+ @shuffled_ids = []
16
+ @epoch = -1
17
+ @id_freqs = Hash.new(0)
18
+ end
19
+
20
+ sig { params(trainset_size: Integer, iteration: Integer).returns(T::Array[Integer]) }
21
+ def next_minibatch_indices(trainset_size, iteration)
22
+ with_span(
23
+ 'gepa.strategies.batch_sampler',
24
+ minibatch_size: @minibatch_size,
25
+ trainset_size: trainset_size,
26
+ iteration: iteration
27
+ ) do
28
+ ensure_epoch(trainset_size, iteration)
29
+ base_idx = (iteration * @minibatch_size) % @shuffled_ids.length
30
+ end_idx = base_idx + @minibatch_size
31
+ @shuffled_ids[base_idx...end_idx]
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ sig { returns(T.untyped) }
38
+ def telemetry
39
+ @telemetry || GEPA::Telemetry
40
+ end
41
+
42
+ sig { params(trainset_size: Integer, iteration: Integer).void }
43
+ def ensure_epoch(trainset_size, iteration)
44
+ update_shuffled(trainset_size) if @shuffled_ids.empty?
45
+
46
+ curr_epoch = if @epoch == -1
47
+ 0
48
+ else
49
+ (iteration * @minibatch_size) / [@shuffled_ids.length, 1].max
50
+ end
51
+
52
+ return unless curr_epoch > @epoch
53
+
54
+ @epoch = curr_epoch
55
+ update_shuffled(trainset_size)
56
+ end
57
+
58
+ sig { params(trainset_size: Integer).void }
59
+ def update_shuffled(trainset_size)
60
+ @shuffled_ids = Array.new(trainset_size) { |idx| idx }
61
+ @shuffled_ids = @shuffled_ids.shuffle(random: @rng)
62
+
63
+ @shuffled_ids.each { |idx| @id_freqs[idx] += 1 }
64
+
65
+ remainder = trainset_size % @minibatch_size
66
+ num_to_pad = remainder.zero? ? 0 : (@minibatch_size - remainder)
67
+
68
+ num_to_pad.times do
69
+ least_used = @id_freqs.min_by { |_idx, count| count }&.first || 0
70
+ @shuffled_ids << least_used
71
+ @id_freqs[least_used] += 1
72
+ end
73
+
74
+ raise ArgumentError, 'minibatch size must be positive' if @minibatch_size <= 0
75
+ raise 'shuffled ids shorter than minibatch size' if @shuffled_ids.length < @minibatch_size
76
+ raise 'shuffled ids not aligned to minibatch size' unless (@shuffled_ids.length % @minibatch_size).zero?
77
+ end
78
+
79
+ sig do
80
+ params(
81
+ operation: String,
82
+ attrs: T::Hash[Symbol, T.untyped],
83
+ block: T.proc.returns(T.untyped)
84
+ ).returns(T.untyped)
85
+ end
86
+ def with_span(operation, attrs = {}, &block)
87
+ telemetry.with_span(operation, attrs, &block)
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module GEPA
6
+ module Strategies
7
+ class ParetoCandidateSelector
8
+ extend T::Sig
9
+
10
+ sig { params(rng: T.nilable(Random), telemetry: T.nilable(T.untyped)).void }
11
+ def initialize(rng: nil, telemetry: nil)
12
+ @rng = rng || Random.new(0)
13
+ @telemetry = telemetry
14
+ end
15
+
16
+ sig { params(state: GEPA::Core::State).returns(Integer) }
17
+ def select_candidate_idx(state)
18
+ ensure_lengths!(state)
19
+ with_span('gepa.strategies.candidate_selector', strategy: 'pareto') do
20
+ scores = state.per_program_tracked_scores.each_with_index.to_h { |score, idx| [idx, score] }
21
+ GEPA::Utils::Pareto.select_program_candidate_from_pareto_front(
22
+ state.program_at_pareto_front_valset,
23
+ scores,
24
+ @rng
25
+ )
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ sig { params(state: GEPA::Core::State).void }
32
+ def ensure_lengths!(state)
33
+ return if state.per_program_tracked_scores.length == state.program_candidates.length
34
+
35
+ raise ArgumentError, 'per_program_tracked_scores and program_candidates length mismatch'
36
+ end
37
+
38
+ sig { returns(T.untyped) }
39
+ def telemetry
40
+ @telemetry || GEPA::Telemetry
41
+ end
42
+
43
+ sig do
44
+ params(
45
+ operation: String,
46
+ attrs: T::Hash[Symbol, T.untyped],
47
+ block: T.proc.returns(T.untyped)
48
+ ).returns(T.untyped)
49
+ end
50
+ def with_span(operation, attrs = {}, &block)
51
+ telemetry.with_span(operation, attrs, &block)
52
+ end
53
+ end
54
+
55
+ class CurrentBestCandidateSelector
56
+ extend T::Sig
57
+
58
+ sig { params(telemetry: T.nilable(T.untyped)).void }
59
+ def initialize(telemetry: nil)
60
+ @telemetry = telemetry
61
+ end
62
+
63
+ sig { params(state: GEPA::Core::State).returns(Integer) }
64
+ def select_candidate_idx(state)
65
+ ensure_lengths!(state)
66
+ with_span('gepa.strategies.candidate_selector', strategy: 'current_best') do
67
+ GEPA::Utils::Pareto.idxmax(state.per_program_tracked_scores)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ sig { params(state: GEPA::Core::State).void }
74
+ def ensure_lengths!(state)
75
+ return if state.per_program_tracked_scores.length == state.program_candidates.length
76
+
77
+ raise ArgumentError, 'per_program_tracked_scores and program_candidates length mismatch'
78
+ end
79
+
80
+ sig { returns(T.untyped) }
81
+ def telemetry
82
+ @telemetry || GEPA::Telemetry
83
+ end
84
+
85
+ sig do
86
+ params(
87
+ operation: String,
88
+ attrs: T::Hash[Symbol, T.untyped],
89
+ block: T.proc.returns(T.untyped)
90
+ ).returns(T.untyped)
91
+ end
92
+ def with_span(operation, attrs = {}, &block)
93
+ telemetry.with_span(operation, attrs, &block)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module GEPA
6
+ module Strategies
7
+ class RoundRobinReflectionComponentSelector
8
+ extend T::Sig
9
+
10
+ sig { params(telemetry: T.nilable(T.untyped)).void }
11
+ def initialize(telemetry: nil)
12
+ @telemetry = telemetry
13
+ end
14
+
15
+ sig do
16
+ params(
17
+ state: GEPA::Core::State,
18
+ trajectories: T::Array[T.untyped],
19
+ subsample_scores: T::Array[Float],
20
+ candidate_idx: Integer,
21
+ candidate: T::Hash[String, String]
22
+ ).returns(T::Array[String])
23
+ end
24
+ def select_modules(state, trajectories, subsample_scores, candidate_idx, candidate)
25
+ with_span(
26
+ 'gepa.strategies.component_selector',
27
+ strategy: 'round_robin',
28
+ candidate_idx: candidate_idx
29
+ ) do
30
+ predictor_id = state.named_predictor_id_to_update_next_for_program_candidate[candidate_idx]
31
+ state.named_predictor_id_to_update_next_for_program_candidate[candidate_idx] =
32
+ (predictor_id + 1) % state.list_of_named_predictors.length
33
+
34
+ [state.list_of_named_predictors[predictor_id]]
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ sig { returns(T.untyped) }
41
+ def telemetry
42
+ @telemetry || GEPA::Telemetry
43
+ end
44
+
45
+ sig do
46
+ params(
47
+ operation: String,
48
+ attrs: T::Hash[Symbol, T.untyped],
49
+ block: T.proc.returns(T.untyped)
50
+ ).returns(T.untyped)
51
+ end
52
+ def with_span(operation, attrs, &block)
53
+ telemetry.with_span(operation, attrs, &block)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module GEPA
6
+ module Strategies
7
+ class InstructionProposalSignature
8
+ extend T::Sig
9
+
10
+ PROMPT_TEMPLATE = <<~PROMPT
11
+ I provided an assistant with the following instructions to perform a task for me:
12
+ ```
13
+ <curr_instructions>
14
+ ```
15
+
16
+ The following are examples of different task inputs provided to the assistant along with the assistant's response for each of them, and some feedback on how the assistant's response could be better:
17
+ ```
18
+ <inputs_outputs_feedback>
19
+ ```
20
+
21
+ Your task is to write a new instruction for the assistant.
22
+
23
+ Read the inputs carefully and identify the input format and infer detailed task description about the task I wish to solve with the assistant.
24
+
25
+ Read all the assistant responses and the corresponding feedback. Identify all niche and domain specific factual information about the task and include it in the instruction, as a lot of it may not be available to the assistant in the future. The assistant may have utilized a generalizable strategy to solve the task, if so, include that in the instruction as well.
26
+
27
+ Provide the new instructions within ``` blocks.
28
+ PROMPT
29
+
30
+ sig { returns(T::Array[String]) }
31
+ def self.input_keys
32
+ %w[current_instruction_doc dataset_with_feedback]
33
+ end
34
+
35
+ sig { returns(T::Array[String]) }
36
+ def self.output_keys
37
+ %w[new_instruction]
38
+ end
39
+
40
+ sig { params(input: T::Hash[String, T.untyped]).returns(String) }
41
+ def self.prompt_renderer(input)
42
+ prompt = PROMPT_TEMPLATE.dup
43
+ prompt = prompt.sub('<curr_instructions>', input.fetch('current_instruction_doc', ''))
44
+ prompt.sub('<inputs_outputs_feedback>', render_samples(input.fetch('dataset_with_feedback', [])))
45
+ end
46
+
47
+ sig { params(output: String).returns(T::Hash[String, String]) }
48
+ def self.output_extractor(output)
49
+ stripped = output.to_s.strip
50
+ return { 'new_instruction' => stripped } if stripped.count('```') < 2
51
+
52
+ first = stripped.index('```')
53
+ last = stripped.rindex('```')
54
+ if first.nil? || last.nil? || first == last
55
+ { 'new_instruction' => stripped.delete_prefix('```').delete_suffix('```').strip }
56
+ else
57
+ inner = stripped[(first + 3)...last].strip
58
+ { 'new_instruction' => inner.empty? ? stripped : inner }
59
+ end
60
+ end
61
+
62
+ sig do
63
+ params(
64
+ lm: T.untyped,
65
+ input_dict: T::Hash[String, T.untyped]
66
+ ).returns(T::Hash[String, String])
67
+ end
68
+ def self.run(lm, input_dict)
69
+ prompt = prompt_renderer(input_dict)
70
+ raw_output = if lm.respond_to?(:call)
71
+ lm.call(prompt)
72
+ else
73
+ response = lm.raw_chat([{ role: 'user', content: prompt }])
74
+ response.respond_to?(:content) ? response.content : response
75
+ end
76
+
77
+ output_extractor(raw_output.to_s)
78
+ end
79
+
80
+ class << self
81
+ extend T::Sig
82
+ private
83
+
84
+ sig { params(samples: T::Array[T.untyped]).returns(String) }
85
+ def render_samples(samples)
86
+ samples.each_with_index.map do |sample, index|
87
+ convert_sample_to_markdown(sample, index + 1)
88
+ end.join("\n\n")
89
+ end
90
+
91
+ sig { params(sample: T.untyped, index: Integer).returns(String) }
92
+ def convert_sample_to_markdown(sample, index)
93
+ return '' unless sample.is_a?(Hash)
94
+
95
+ sample.map do |key, value|
96
+ "## Example #{index}\n### #{key}\n#{render_value(value, 4)}"
97
+ end.join
98
+ end
99
+
100
+ sig { params(value: T.untyped, level: Integer).returns(String) }
101
+ def render_value(value, level)
102
+ case value
103
+ when Hash
104
+ value.map do |key, val|
105
+ heading = '#' * [level, 6].min
106
+ "#{heading} #{key}\n#{render_value(val, level + 1)}"
107
+ end.join
108
+ when Array
109
+ value.each_with_index.map do |item, idx|
110
+ heading = '#' * [level, 6].min
111
+ "#{heading} Item #{idx + 1}\n#{render_value(item, level + 1)}"
112
+ end.join
113
+ else
114
+ "#{value}\n\n"
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'sorbet-runtime'
5
+ require 'dspy'
6
+
7
+ module GEPA
8
+ # Telemetry helpers for the GEPA optimizer.
9
+ #
10
+ # The helpers wrap DSPy context spans and structured logs so that the GEPA
11
+ # port can attach observability data consistently across the optimization
12
+ # lifecycle. They mirror the phases from the Python sequence diagrams:
13
+ #
14
+ # - `gepa.optimize` (API entry)
15
+ # - `gepa.state.initialize`
16
+ # - `gepa.engine.run` / `gepa.engine.iteration`
17
+ # - `gepa.proposer.*` (selection, evaluation, reflection, acceptance)
18
+ #
19
+ # Later phases of the port can depend on these helpers without reimplementing
20
+ # span naming or default attributes.
21
+ module Telemetry
22
+ extend T::Sig
23
+
24
+ DEFAULT_ATTRIBUTES = T.let({
25
+ optimizer: 'GEPA',
26
+ 'gepa.instrumentation_version': 'phase0',
27
+ 'langfuse.observation.type': 'span'
28
+ }.freeze, T::Hash[Symbol, T.untyped])
29
+
30
+ class Context < T::Struct
31
+ extend T::Sig
32
+
33
+ const :run_id, String
34
+ const :attributes, T::Hash[Symbol, T.untyped]
35
+
36
+ sig do
37
+ params(
38
+ operation: String,
39
+ metadata: T::Hash[T.any(String, Symbol), T.untyped],
40
+ block: T.proc.returns(T.untyped)
41
+ ).returns(T.untyped)
42
+ end
43
+ def with_span(operation, metadata = {}, &block)
44
+ Telemetry.with_span(operation, base_attributes.merge(Telemetry.send(:symbolize, metadata)), &block)
45
+ end
46
+
47
+ sig do
48
+ params(
49
+ event_name: String,
50
+ metadata: T::Hash[T.any(String, Symbol), T.untyped]
51
+ ).void
52
+ end
53
+ def emit(event_name, metadata = {})
54
+ Telemetry.emit(event_name, base_attributes.merge(Telemetry.send(:symbolize, metadata)))
55
+ end
56
+
57
+ private
58
+
59
+ sig { returns(T::Hash[Symbol, T.untyped]) }
60
+ def base_attributes
61
+ attributes.merge(run_id: run_id)
62
+ end
63
+ end
64
+
65
+ sig do
66
+ params(
67
+ additional_attributes: T::Hash[T.any(String, Symbol), T.untyped]
68
+ ).returns(Context)
69
+ end
70
+ def self.build_context(additional_attributes = {})
71
+ attributes = DEFAULT_ATTRIBUTES.merge(symbolize(additional_attributes.dup))
72
+ run_id = attributes.delete(:run_id) || SecureRandom.uuid
73
+
74
+ Context.new(run_id: run_id, attributes: attributes)
75
+ end
76
+
77
+ sig do
78
+ params(
79
+ operation: String,
80
+ attributes: T::Hash[T.any(String, Symbol), T.untyped],
81
+ block: T.proc.returns(T.untyped)
82
+ ).returns(T.untyped)
83
+ end
84
+ def self.with_span(operation, attributes = {}, &block)
85
+ operation_name = normalize_operation(operation)
86
+ span_attributes = DEFAULT_ATTRIBUTES.merge(symbolize(attributes))
87
+
88
+ DSPy::Context.with_span(operation: operation_name, **span_attributes, &block)
89
+ end
90
+
91
+ sig do
92
+ params(
93
+ event_name: String,
94
+ attributes: T::Hash[T.any(String, Symbol), T.untyped]
95
+ ).void
96
+ end
97
+ def self.emit(event_name, attributes = {})
98
+ payload = DEFAULT_ATTRIBUTES.merge(symbolize(attributes))
99
+ DSPy.log("gepa.#{event_name}", **payload)
100
+ end
101
+
102
+ sig { params(operation: String).returns(String) }
103
+ def self.normalize_operation(operation)
104
+ return operation if operation.start_with?('gepa.')
105
+
106
+ "gepa.#{operation}"
107
+ end
108
+ private_class_method :normalize_operation
109
+
110
+ sig do
111
+ params(
112
+ attributes: T::Hash[T.any(String, Symbol), T.untyped]
113
+ ).returns(T::Hash[Symbol, T.untyped])
114
+ end
115
+ def self.symbolize(attributes)
116
+ attributes.each_with_object({}) do |(key, value), acc|
117
+ acc[key.to_sym] = value
118
+ end
119
+ end
120
+ private_class_method :symbolize
121
+ end
122
+ end