dspy 0.28.1 → 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.
- checksums.yaml +4 -4
- data/README.md +2 -3
- data/lib/dspy/callbacks.rb +222 -0
- data/lib/dspy/chain_of_thought.rb +2 -1
- data/lib/dspy/code_act.rb +14 -1
- data/lib/dspy/datasets/ade.rb +90 -0
- data/lib/dspy/datasets.rb +8 -0
- data/lib/dspy/lm.rb +9 -12
- data/lib/dspy/mixins/struct_builder.rb +17 -25
- data/lib/dspy/module.rb +45 -1
- data/lib/dspy/observability/async_span_processor.rb +67 -93
- data/lib/dspy/observability.rb +43 -1
- data/lib/dspy/predict.rb +17 -0
- data/lib/dspy/prompt.rb +90 -20
- data/lib/dspy/propose/dataset_summary_generator.rb +210 -0
- data/lib/dspy/propose/grounded_proposer.rb +320 -66
- data/lib/dspy/re_act.rb +13 -0
- data/lib/dspy/reflection_lm.rb +36 -0
- data/lib/dspy/teleprompt/bootstrap_strategy.rb +26 -0
- data/lib/dspy/teleprompt/gepa.rb +448 -2803
- data/lib/dspy/teleprompt/mipro_v2.rb +624 -100
- data/lib/dspy/teleprompt/utils.rb +349 -42
- data/lib/dspy/version.rb +2 -2
- data/lib/dspy.rb +4 -2
- data/lib/gepa/api.rb +61 -0
- data/lib/gepa/core/engine.rb +226 -0
- data/lib/gepa/core/evaluation_batch.rb +26 -0
- data/lib/gepa/core/result.rb +92 -0
- data/lib/gepa/core/state.rb +231 -0
- data/lib/gepa/logging/experiment_tracker.rb +54 -0
- data/lib/gepa/logging/logger.rb +57 -0
- data/lib/gepa/logging.rb +9 -0
- data/lib/gepa/proposer/base.rb +27 -0
- data/lib/gepa/proposer/merge_proposer.rb +424 -0
- data/lib/gepa/proposer/reflective_mutation/base.rb +48 -0
- data/lib/gepa/proposer/reflective_mutation/reflective_mutation.rb +188 -0
- data/lib/gepa/strategies/batch_sampler.rb +91 -0
- data/lib/gepa/strategies/candidate_selector.rb +97 -0
- data/lib/gepa/strategies/component_selector.rb +57 -0
- data/lib/gepa/strategies/instruction_proposal.rb +120 -0
- data/lib/gepa/telemetry.rb +122 -0
- data/lib/gepa/utils/pareto.rb +119 -0
- data/lib/gepa.rb +21 -0
- metadata +59 -4
- data/lib/dspy/teleprompt/simple_optimizer.rb +0 -497
@@ -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
|
@@ -0,0 +1,119 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'json'
|
4
|
+
require 'set'
|
5
|
+
require 'sorbet-runtime'
|
6
|
+
|
7
|
+
module GEPA
|
8
|
+
module Utils
|
9
|
+
module Pareto
|
10
|
+
extend T::Sig
|
11
|
+
|
12
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
13
|
+
def self.json_default(value)
|
14
|
+
value.is_a?(Hash) ? value.transform_keys(&:to_s) : JSON.parse(value.to_json)
|
15
|
+
rescue StandardError
|
16
|
+
{ value: value.to_s }
|
17
|
+
end
|
18
|
+
|
19
|
+
sig { params(values: T::Array[Float]).returns(Integer) }
|
20
|
+
def self.idxmax(values)
|
21
|
+
raise ArgumentError, 'values must not be empty' if values.empty?
|
22
|
+
|
23
|
+
values.each_with_index.max_by { |score, _i| score }&.last || 0
|
24
|
+
end
|
25
|
+
|
26
|
+
sig do
|
27
|
+
params(
|
28
|
+
program_at_pareto_front_valset: T::Array[T.untyped],
|
29
|
+
scores: T.nilable(T::Hash[Integer, Float])
|
30
|
+
).returns(T::Array[T.untyped])
|
31
|
+
end
|
32
|
+
def self.remove_dominated_programs(program_at_pareto_front_valset, scores: nil)
|
33
|
+
normalized_fronts = program_at_pareto_front_valset.map { |front| front.to_a }
|
34
|
+
|
35
|
+
frequency = Hash.new(0)
|
36
|
+
normalized_fronts.each do |front|
|
37
|
+
front.each { |program_idx| frequency[program_idx] += 1 }
|
38
|
+
end
|
39
|
+
|
40
|
+
all_programs = frequency.keys
|
41
|
+
scores ||= all_programs.to_h { |idx| [idx, 1.0] }
|
42
|
+
|
43
|
+
sorted_programs = all_programs.sort_by { |idx| scores.fetch(idx, 0.0) }
|
44
|
+
|
45
|
+
dominated = Set.new
|
46
|
+
loop do
|
47
|
+
found = false
|
48
|
+
sorted_programs.each do |candidate|
|
49
|
+
next if dominated.include?(candidate)
|
50
|
+
next unless dominated?(candidate, sorted_programs.to_set, dominated, normalized_fronts)
|
51
|
+
|
52
|
+
dominated.add(candidate)
|
53
|
+
found = true
|
54
|
+
break
|
55
|
+
end
|
56
|
+
break unless found
|
57
|
+
end
|
58
|
+
|
59
|
+
dominators = sorted_programs.reject { |idx| dominated.include?(idx) }
|
60
|
+
dominators_set = dominators.to_set
|
61
|
+
|
62
|
+
normalized_fronts.map do |front|
|
63
|
+
front.select { |idx| dominators_set.include?(idx) }
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
sig do
|
68
|
+
params(
|
69
|
+
pareto_front_programs: T::Array[T.untyped],
|
70
|
+
train_val_weighted_scores: T::Hash[Integer, Float]
|
71
|
+
).returns(T::Array[Integer])
|
72
|
+
end
|
73
|
+
def self.find_dominator_programs(pareto_front_programs, train_val_weighted_scores)
|
74
|
+
cleaned_frontiers = remove_dominated_programs(pareto_front_programs, scores: train_val_weighted_scores)
|
75
|
+
cleaned_frontiers.flat_map(&:to_a).uniq
|
76
|
+
end
|
77
|
+
|
78
|
+
sig do
|
79
|
+
params(
|
80
|
+
pareto_front_programs: T::Array[T.untyped],
|
81
|
+
weighted_scores: T::Hash[Integer, Float],
|
82
|
+
rng: Random
|
83
|
+
).returns(Integer)
|
84
|
+
end
|
85
|
+
def self.select_program_candidate_from_pareto_front(pareto_front_programs, weighted_scores, rng)
|
86
|
+
cleaned_frontiers = remove_dominated_programs(pareto_front_programs, scores: weighted_scores)
|
87
|
+
frequency = Hash.new(0)
|
88
|
+
cleaned_frontiers.each do |front|
|
89
|
+
front.each { |idx| frequency[idx] += 1 }
|
90
|
+
end
|
91
|
+
raise ArgumentError, 'pareto front is empty' if frequency.empty?
|
92
|
+
|
93
|
+
sampling_list = frequency.flat_map { |idx, freq| [idx] * freq }
|
94
|
+
sampling_list[rng.rand(sampling_list.length)]
|
95
|
+
end
|
96
|
+
|
97
|
+
class << self
|
98
|
+
extend T::Sig
|
99
|
+
private
|
100
|
+
|
101
|
+
sig do
|
102
|
+
params(
|
103
|
+
candidate: Integer,
|
104
|
+
program_set: Set,
|
105
|
+
dominated: Set,
|
106
|
+
pareto_fronts: T::Array[T::Array[Integer]]
|
107
|
+
).returns(T::Boolean)
|
108
|
+
end
|
109
|
+
def dominated?(candidate, program_set, dominated, pareto_fronts)
|
110
|
+
candidate_fronts = pareto_fronts.select { |front| front.include?(candidate) }
|
111
|
+
candidate_fronts.all? do |front|
|
112
|
+
remaining = front.reject { |idx| idx == candidate || dominated.include?(idx) }
|
113
|
+
remaining.any? { |other| program_set.include?(other) }
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
data/lib/gepa.rb
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative 'gepa/telemetry'
|
4
|
+
require_relative 'gepa/logging'
|
5
|
+
require_relative 'gepa/utils/pareto'
|
6
|
+
require_relative 'gepa/strategies/batch_sampler'
|
7
|
+
require_relative 'gepa/strategies/candidate_selector'
|
8
|
+
require_relative 'gepa/strategies/component_selector'
|
9
|
+
require_relative 'gepa/strategies/instruction_proposal'
|
10
|
+
require_relative 'gepa/core/evaluation_batch'
|
11
|
+
require_relative 'gepa/core/result'
|
12
|
+
require_relative 'gepa/core/state'
|
13
|
+
require_relative 'gepa/core/engine'
|
14
|
+
require_relative 'gepa/proposer/base'
|
15
|
+
require_relative 'gepa/proposer/reflective_mutation/base'
|
16
|
+
require_relative 'gepa/proposer/reflective_mutation/reflective_mutation'
|
17
|
+
require_relative 'gepa/proposer/merge_proposer'
|
18
|
+
require_relative 'gepa/api'
|
19
|
+
|
20
|
+
module GEPA
|
21
|
+
end
|