braintrust 0.0.7 → 0.0.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ad055b60c4efb984bce955b1c00c684c760c849dea7a54d49a452f925dbab629
4
- data.tar.gz: fe271abfac7810e53ff88efb139bc41b519799ed7dbd196ab5bb64fcbc35b62c
3
+ metadata.gz: be2efc651c8e685179541cf2ade46f86e3ca66c408ed00707d2b9890a4d5fa72
4
+ data.tar.gz: ba9c9f993abf5ea64290c5510361297a6b7dca21ad06583634d7e4080d4f7531
5
5
  SHA512:
6
- metadata.gz: a33fc58073542bf7d7dbf45092d9f3a6669d7f13fa98d3d8753d8fccd456c7e010b8a3ac0be5625e6761e164d2223f20b7fbaa3f02ead978f47398153c6c8ac2
7
- data.tar.gz: b24f1377f4ec25f09c7c5e1366e1428c9164d7b3b7bdcf82331aa84d10603b250c61983ea811a13e7be1a1276558ef2995292fbefb20e99a374e59e9eaefb8b1
6
+ metadata.gz: a40a56eae61148496ee7c96775b9c6fef63106a5c943dbe1a43f66572cb245e5ec3fd5a2e98452926c8a326a0f06f9dd68bfb56a4b7f04081e65ec03ef3d7939
7
+ data.tar.gz: c1bed8505efca929e688538d293e4f329c8dd8c2d1152858bb6967fa0d9f54b88f4120fb3d1f7d591fc51cc0e05ec1577b8345524ddc45863c1eb2ff6537a334
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Eval
5
+ # Formatter for pretty CLI output of experiment results
6
+ # Uses ANSI colors and Unicode box drawing for terminal display
7
+ module Formatter
8
+ # ANSI color codes
9
+ COLORS = {
10
+ gray: "\e[90m",
11
+ red: "\e[31m",
12
+ green: "\e[32m",
13
+ blue: "\e[34m",
14
+ magenta: "\e[35m",
15
+ white: "\e[97m",
16
+ dim: "\e[2m",
17
+ reset: "\e[0m"
18
+ }.freeze
19
+
20
+ # Box drawing characters (Unicode)
21
+ BOX = {
22
+ top_left: "╭",
23
+ top_right: "╮",
24
+ bottom_left: "╰",
25
+ bottom_right: "╯",
26
+ horizontal: "─",
27
+ vertical: "│"
28
+ }.freeze
29
+
30
+ # Maximum length for error messages before truncation
31
+ MAX_ERROR_LENGTH = 150
32
+
33
+ class << self
34
+ # Format an experiment summary for CLI output
35
+ # @param summary [ExperimentSummary] The experiment summary
36
+ # @return [String] Formatted output with box drawing and colors
37
+ def format_experiment_summary(summary)
38
+ return "" unless summary
39
+
40
+ lines = []
41
+
42
+ # Metadata section
43
+ lines << format_metadata_row("Project", summary.project_name)
44
+ lines << format_metadata_row("Experiment", summary.experiment_name)
45
+ lines << format_metadata_row("ID", summary.experiment_id)
46
+ lines << format_metadata_row("Duration", format_duration(summary.duration))
47
+ lines << format_metadata_row("Errors", summary.error_count.to_s)
48
+
49
+ # Scores section (if any)
50
+ if summary.scores&.any?
51
+ lines << ""
52
+ lines << colorize("Scores", :white)
53
+
54
+ # Calculate max scorer name length for alignment
55
+ max_name_len = summary.scores.values.map { |s| s.name.length }.max || 0
56
+ name_width = [max_name_len + 2, 20].max # +2 for "◯ " prefix
57
+
58
+ summary.scores.each_value do |score|
59
+ lines << format_score_row(score, name_width)
60
+ end
61
+ end
62
+
63
+ # Errors section (if any)
64
+ if summary.errors&.any?
65
+ lines << ""
66
+ lines << colorize("Errors", :white)
67
+
68
+ summary.errors.each do |error|
69
+ lines << format_error_row(error)
70
+ end
71
+ end
72
+
73
+ # Footer link
74
+ if summary.experiment_url
75
+ lines << ""
76
+ lines << terminal_link("View results for #{summary.experiment_name}", summary.experiment_url)
77
+ end
78
+
79
+ wrap_in_box(lines, "Experiment summary")
80
+ end
81
+
82
+ # Format a metadata row (label: value)
83
+ # @param label [String] Row label
84
+ # @param value [String] Row value
85
+ # @return [String] Formatted row
86
+ def format_metadata_row(label, value)
87
+ "#{colorize(label + ":", :dim)} #{value}"
88
+ end
89
+
90
+ # Format duration for display
91
+ # @param duration [Float] Duration in seconds
92
+ # @return [String] Formatted duration (e.g., "1.2345s" or "123ms")
93
+ def format_duration(duration)
94
+ if duration < 1
95
+ "#{(duration * 1000).round(0)}ms"
96
+ else
97
+ "#{duration.round(4)}s"
98
+ end
99
+ end
100
+
101
+ # Format an error row for display
102
+ # @param error_message [String] The error message
103
+ # @return [String] Formatted row with red ✗
104
+ def format_error_row(error_message)
105
+ truncated = truncate_error(error_message, MAX_ERROR_LENGTH)
106
+ "#{colorize("✗", :red)} #{truncated}"
107
+ end
108
+
109
+ # Truncate error message to max length with ellipsis
110
+ # @param message [String] The error message
111
+ # @param max_length [Integer] Maximum length before truncation
112
+ # @return [String] Truncated message
113
+ def truncate_error(message, max_length)
114
+ return message if message.length <= max_length
115
+ "#{message[0, max_length - 3]}..."
116
+ end
117
+
118
+ # Apply ANSI color codes to text
119
+ # @param text [String] Text to colorize
120
+ # @param styles [Array<Symbol>] Color/style names (:gray, :red, :green, etc.)
121
+ # @return [String] Colorized text (or plain text if not a TTY)
122
+ def colorize(text, *styles)
123
+ return text unless $stdout.tty?
124
+ codes = styles.map { |s| COLORS[s] }.compact.join
125
+ "#{codes}#{text}#{COLORS[:reset]}"
126
+ end
127
+
128
+ # Format a score row for display
129
+ # @param score [ScorerStats] The scorer statistics
130
+ # @param name_width [Integer] Width for the name column
131
+ # @return [String] Formatted row
132
+ def format_score_row(score, name_width = 20)
133
+ name = "#{colorize("◯", :blue)} #{score.name}"
134
+ value = colorize("#{(score.score_mean * 100).round(2)}%", :white)
135
+ pad_cell(name, name_width, :left) + " " + pad_cell(value, 10, :right)
136
+ end
137
+
138
+ # Pad a cell to a given width, accounting for ANSI codes
139
+ # @param text [String] Cell text (may contain ANSI codes)
140
+ # @param width [Integer] Target width
141
+ # @param align [Symbol] :left or :right alignment
142
+ # @return [String] Padded cell
143
+ def pad_cell(text, width, align)
144
+ visible_length = visible_text_length(text)
145
+ padding = [width - visible_length, 0].max
146
+
147
+ case align
148
+ when :right
149
+ " " * padding + text
150
+ else
151
+ text + " " * padding
152
+ end
153
+ end
154
+
155
+ # Calculate visible text length, stripping ANSI codes and OSC 8 hyperlinks
156
+ # @param text [String] Text that may contain escape sequences
157
+ # @return [Integer] Visible character count
158
+ def visible_text_length(text)
159
+ # Strip ANSI color codes: \e[...m
160
+ # Strip OSC 8 hyperlinks: \e]8;;...\e\\ (the URL part is invisible)
161
+ text
162
+ .gsub(/\e\[[0-9;]*m/, "") # ANSI color codes
163
+ .gsub(/\e\]8;;[^\e]*\e\\/, "") # OSC 8 hyperlink sequences
164
+ .length
165
+ end
166
+
167
+ # Create a clickable terminal hyperlink (OSC 8)
168
+ # @param text [String] Display text
169
+ # @param url [String] Target URL
170
+ # @return [String] Hyperlinked text (or plain text with URL if not a TTY)
171
+ def terminal_link(text, url)
172
+ if $stdout.tty?
173
+ "\e]8;;#{url}\e\\#{text}\e]8;;\e\\"
174
+ else
175
+ "#{text}: #{url}"
176
+ end
177
+ end
178
+
179
+ # Wrap content lines in a Unicode box with title
180
+ # @param lines [Array<String>] Content lines
181
+ # @param title [String] Box title
182
+ # @return [String] Boxed content
183
+ def wrap_in_box(lines, title)
184
+ # Calculate width from content (strip escape sequences for measurement)
185
+ content_width = lines.map { |l| visible_text_length(l) }.max || 0
186
+ box_width = [content_width + 4, title.length + 6].max
187
+ inner_width = box_width - 2
188
+
189
+ result = []
190
+
191
+ # Top border with title
192
+ title_str = " #{title} "
193
+ remaining = inner_width - title_str.length - 1
194
+ top = colorize("#{BOX[:top_left]}#{BOX[:horizontal]}", :gray) +
195
+ colorize(title_str, :gray) +
196
+ colorize(BOX[:horizontal] * remaining + BOX[:top_right], :gray)
197
+ result << top
198
+
199
+ # Empty line for padding
200
+ result << colorize(BOX[:vertical], :gray) + " " * inner_width + colorize(BOX[:vertical], :gray)
201
+
202
+ # Content lines
203
+ lines.each do |line|
204
+ visible_len = visible_text_length(line)
205
+ padding = inner_width - visible_len - 2 # 1 space on each side
206
+ result << colorize(BOX[:vertical], :gray) + " " + line + " " * [padding, 0].max + " " + colorize(BOX[:vertical], :gray)
207
+ end
208
+
209
+ # Empty line for padding
210
+ result << colorize(BOX[:vertical], :gray) + " " * inner_width + colorize(BOX[:vertical], :gray)
211
+
212
+ # Bottom border
213
+ result << colorize("#{BOX[:bottom_left]}#{BOX[:horizontal] * inner_width}#{BOX[:bottom_right]}", :gray)
214
+
215
+ "\n" + result.join("\n")
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "formatter"
4
+ require_relative "summary"
5
+
3
6
  module Braintrust
4
7
  module Eval
5
8
  # Result represents the outcome of an evaluation run
6
- # Contains experiment metadata, errors, and timing information
9
+ # Contains experiment metadata, errors, timing information, and raw score data
7
10
  class Result
8
11
  attr_reader :experiment_id, :experiment_name, :project_id, :project_name,
9
- :permalink, :errors, :duration
12
+ :permalink, :errors, :duration, :scores
10
13
 
11
14
  # Create a new result
12
15
  # @param experiment_id [String] The experiment ID
@@ -16,8 +19,9 @@ module Braintrust
16
19
  # @param permalink [String] Link to view the experiment in Braintrust UI
17
20
  # @param errors [Array<String>] List of errors that occurred
18
21
  # @param duration [Float] Duration in seconds
22
+ # @param scores [Hash, nil] Raw score data { scorer_name => Array<Numeric> }
19
23
  def initialize(experiment_id:, experiment_name:, project_id:, project_name:,
20
- permalink:, errors:, duration:)
24
+ permalink:, errors:, duration:, scores: nil)
21
25
  @experiment_id = experiment_id
22
26
  @experiment_name = experiment_name
23
27
  @project_id = project_id
@@ -25,6 +29,7 @@ module Braintrust
25
29
  @permalink = permalink
26
30
  @errors = errors
27
31
  @duration = duration
32
+ @scores = scores
28
33
  end
29
34
 
30
35
  # Check if the evaluation was successful (no errors)
@@ -39,6 +44,12 @@ module Braintrust
39
44
  !success?
40
45
  end
41
46
 
47
+ # Get the experiment summary (lazily computed)
48
+ # @return [ExperimentSummary] Summary view model for Formatter
49
+ def summary
50
+ @summary ||= build_summary
51
+ end
52
+
42
53
  # Format the result as a human-readable string (Go SDK format)
43
54
  # @return [String]
44
55
  def to_s
@@ -51,6 +62,49 @@ module Braintrust
51
62
  "Errors: #{errors.length}"
52
63
  ].join("\n")
53
64
  end
65
+
66
+ # Format the result as a pretty CLI output with box drawing and colors
67
+ # @return [String]
68
+ def to_pretty
69
+ Formatter.format_experiment_summary(summary)
70
+ end
71
+
72
+ # Get statistics for all scorers (lazily computed from scores)
73
+ # @return [Hash<String, ScorerStats>] Scorer stats keyed by scorer name
74
+ def scorer_stats
75
+ @scorer_stats ||= build_scorer_stats
76
+ end
77
+
78
+ private
79
+
80
+ # Build scorer statistics from raw score data
81
+ # @return [Hash<String, ScorerStats>] Scorer stats keyed by scorer name
82
+ def build_scorer_stats
83
+ return {} if scores.nil? || scores.empty?
84
+
85
+ stats = {}
86
+ scores.each do |name, score_values|
87
+ next if score_values.empty?
88
+ mean = score_values.sum.to_f / score_values.size
89
+ stats[name] = ScorerStats.new(name: name, score_mean: mean)
90
+ end
91
+ stats
92
+ end
93
+
94
+ # Build experiment summary view model
95
+ # @return [ExperimentSummary] Summary with all data for Formatter
96
+ def build_summary
97
+ ExperimentSummary.new(
98
+ project_name: project_name,
99
+ experiment_name: experiment_name,
100
+ experiment_id: experiment_id,
101
+ experiment_url: permalink,
102
+ scores: scorer_stats,
103
+ duration: duration,
104
+ error_count: errors.length,
105
+ errors: errors
106
+ )
107
+ end
54
108
  end
55
109
  end
56
110
  end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "case"
4
+ require_relative "cases"
5
+ require_relative "scorer"
6
+ require_relative "result"
7
+ require_relative "summary"
8
+ require_relative "../internal/thread_pool"
9
+
10
+ require "opentelemetry/sdk"
11
+ require "json"
12
+
13
+ module Braintrust
14
+ module Eval
15
+ # Internal runner class that performs the execution of the Eval and returns the result
16
+ class Runner
17
+ # Maximum parallelism allowed (mirrors Internal::ThreadPool::MAX_PARALLELISM)
18
+ MAX_PARALLELISM = Internal::ThreadPool::MAX_PARALLELISM
19
+
20
+ def initialize(experiment_id:, experiment_name:, project_id:, project_name:,
21
+ task:, scorers:, state:, tracer_provider: nil)
22
+ @experiment_id = experiment_id
23
+ @experiment_name = experiment_name
24
+ @project_id = project_id
25
+ @project_name = project_name
26
+ @task = task
27
+ @scorers = normalize_scorers(scorers)
28
+ @state = state
29
+ @tracer_provider = tracer_provider || OpenTelemetry.tracer_provider
30
+ @tracer = @tracer_provider.tracer("braintrust-eval")
31
+ @parent_attr = "experiment_id:#{experiment_id}"
32
+
33
+ # Mutex for thread-safe score collection
34
+ @score_mutex = Mutex.new
35
+ end
36
+
37
+ # Run evaluation and return Result
38
+ # @param cases [Array, Enumerable] Test cases
39
+ # @param parallelism [Integer] Number of parallel workers (default: 1)
40
+ # @return [Result]
41
+ def run(cases, parallelism: 1)
42
+ start_time = Time.now
43
+ normalized_cases = normalize_cases(cases)
44
+ errors = Queue.new
45
+ @scores = {} # Reset for each run: { scorer_name => Array<Numeric> }
46
+
47
+ if parallelism && parallelism > 1
48
+ Internal::ThreadPool.each(normalized_cases, parallelism: parallelism) do |test_case|
49
+ run_case(test_case, errors)
50
+ end
51
+ else
52
+ normalized_cases.each do |test_case|
53
+ run_case(test_case, errors)
54
+ end
55
+ end
56
+
57
+ # Convert Queue to Array after all threads complete
58
+ error_array = [].tap { |a| a << errors.pop until errors.empty? }
59
+
60
+ # Calculate duration
61
+ duration = Time.now - start_time
62
+
63
+ # Generate permalink
64
+ permalink = "#{state.app_url}/app/#{state.org_name}/object?object_type=experiment&object_id=#{experiment_id}"
65
+
66
+ Result.new(
67
+ experiment_id: experiment_id,
68
+ experiment_name: experiment_name,
69
+ project_id: project_id,
70
+ project_name: project_name,
71
+ permalink: permalink,
72
+ errors: error_array,
73
+ duration: duration,
74
+ scores: @scores
75
+ )
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :experiment_id, :experiment_name, :project_id, :project_name,
81
+ :task, :scorers, :state, :tracer, :parent_attr
82
+
83
+ # Run a single test case with OpenTelemetry tracing
84
+ # Creates eval span (parent) with task and score as children
85
+ # @param test_case [Case] The test case
86
+ # @param errors [Queue] Thread-safe error collection queue
87
+ def run_case(test_case, errors)
88
+ tracer.in_span("eval") do |eval_span|
89
+ eval_span.set_attribute("braintrust.parent", parent_attr)
90
+
91
+ # Set tags early so they're present even if task fails
92
+ eval_span.set_attribute("braintrust.tags", test_case.tags) if test_case.tags
93
+
94
+ # Run task
95
+ output = nil
96
+ begin
97
+ output = run_task(test_case)
98
+ rescue => e
99
+ # Error already recorded on task span, set eval span status
100
+ eval_span.status = OpenTelemetry::Trace::Status.error(e.message)
101
+ errors << "Task failed for input '#{test_case.input}': #{e.message}"
102
+ next
103
+ end
104
+
105
+ # Run scorers
106
+ begin
107
+ run_scorers(test_case, output)
108
+ rescue => e
109
+ # Error already recorded on score span, set eval span status
110
+ eval_span.status = OpenTelemetry::Trace::Status.error(e.message)
111
+ errors << "Scorers failed for input '#{test_case.input}': #{e.message}"
112
+ end
113
+
114
+ # Set eval span attributes (after task and scorers complete)
115
+ set_json_attr(eval_span, "braintrust.span_attributes", {type: "eval"})
116
+ set_json_attr(eval_span, "braintrust.input_json", test_case.input)
117
+ set_json_attr(eval_span, "braintrust.output_json", output)
118
+ set_json_attr(eval_span, "braintrust.expected", test_case.expected) if test_case.expected
119
+ end
120
+ end
121
+
122
+ # Run task with OpenTelemetry tracing
123
+ # Creates task span with input and output
124
+ # @param test_case [Case] The test case
125
+ # @return [Object] Task output
126
+ def run_task(test_case)
127
+ tracer.in_span("task") do |task_span|
128
+ task_span.set_attribute("braintrust.parent", parent_attr)
129
+ set_json_attr(task_span, "braintrust.span_attributes", {type: "task"})
130
+ set_json_attr(task_span, "braintrust.input_json", test_case.input)
131
+
132
+ begin
133
+ output = task.call(test_case.input)
134
+ set_json_attr(task_span, "braintrust.output_json", output)
135
+ output
136
+ rescue => e
137
+ # Record exception event with stacktrace, then set error status
138
+ task_span.record_exception(e)
139
+ task_span.status = OpenTelemetry::Trace::Status.error(e.message)
140
+ raise
141
+ end
142
+ end
143
+ end
144
+
145
+ # Run scorers with OpenTelemetry tracing
146
+ # Creates single score span for all scorers
147
+ # @param test_case [Case] The test case
148
+ # @param output [Object] Task output
149
+ def run_scorers(test_case, output)
150
+ tracer.in_span("score") do |score_span|
151
+ score_span.set_attribute("braintrust.parent", parent_attr)
152
+ set_json_attr(score_span, "braintrust.span_attributes", {type: "score"})
153
+
154
+ scores = {}
155
+ scorer_error = nil
156
+ scorers.each do |scorer|
157
+ score_value = scorer.call(test_case.input, test_case.expected, output, test_case.metadata || {})
158
+ scores[scorer.name] = score_value
159
+
160
+ # Collect raw score for summary (thread-safe)
161
+ collect_score(scorer.name, score_value)
162
+ rescue => e
163
+ # Record first error but continue processing other scorers
164
+ scorer_error ||= e
165
+ record_span_error(score_span, e, "ScorerError")
166
+ end
167
+
168
+ # Always set scores attribute, even if some scorers failed
169
+ set_json_attr(score_span, "braintrust.scores", scores)
170
+
171
+ # Raise after setting scores so we can see which scorers succeeded
172
+ raise scorer_error if scorer_error
173
+ end
174
+ end
175
+
176
+ # Normalize cases input to Cases wrapper
177
+ # @param cases_input [Array, Enumerable, Cases] The cases input
178
+ # @return [Cases]
179
+ def normalize_cases(cases_input)
180
+ case cases_input
181
+ when Cases
182
+ cases_input
183
+ when Array, Enumerable
184
+ Cases.new(cases_input)
185
+ else
186
+ if cases_input.respond_to?(:each)
187
+ Cases.new(cases_input)
188
+ else
189
+ raise ArgumentError, "cases must be Array or Enumerable"
190
+ end
191
+ end
192
+ end
193
+
194
+ # Normalize scorers to Scorer objects
195
+ # @param scorers_input [Array] The scorers input (Scorer objects or callables)
196
+ # @return [Array<Scorer>]
197
+ def normalize_scorers(scorers_input)
198
+ scorers_input.map do |scorer|
199
+ case scorer
200
+ when Scorer
201
+ scorer
202
+ else
203
+ Scorer.new(scorer)
204
+ end
205
+ end
206
+ end
207
+
208
+ # Record error on span with exception event and error status
209
+ # @param span [OpenTelemetry::Trace::Span] The span to record error on
210
+ # @param error [Exception] The error that occurred
211
+ # @param error_type [String] The error type name (optional)
212
+ def record_span_error(span, error, error_type = nil)
213
+ if error_type
214
+ span.record_exception(error, attributes: {"exception.type" => error_type})
215
+ else
216
+ span.record_exception(error)
217
+ end
218
+ span.status = OpenTelemetry::Trace::Status.error(error.message)
219
+ end
220
+
221
+ # Set a span attribute by JSON encoding the value
222
+ # @param span [OpenTelemetry::Trace::Span] The span
223
+ # @param key [String] The attribute key
224
+ # @param value [Object] The value to JSON encode
225
+ def set_json_attr(span, key, value)
226
+ span.set_attribute(key, JSON.dump(value))
227
+ end
228
+
229
+ # Collect a single score value for summary calculation
230
+ # @param name [String] Scorer name
231
+ # @param value [Object] Score value (only Numeric values are collected)
232
+ def collect_score(name, value)
233
+ return unless value.is_a?(Numeric)
234
+
235
+ @score_mutex.synchronize do
236
+ (@scores[name] ||= []) << value
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Braintrust
4
+ module Eval
5
+ # Aggregated statistics for a single scorer across test cases
6
+ # @attr name [String] Scorer name
7
+ # @attr score_mean [Float] Mean score (0.0 to 1.0)
8
+ ScorerStats = Struct.new(:name, :score_mean, keyword_init: true)
9
+
10
+ # Summary of results from an Experiment
11
+ # Typically used to generate experiment output
12
+ # @attr project_name [String] Project name
13
+ # @attr experiment_name [String] Experiment name
14
+ # @attr experiment_id [String] Experiment ID
15
+ # @attr experiment_url [String] URL to view experiment in Braintrust UI
16
+ # @attr scores [Hash<String, ScorerStats>] Scorer stats keyed by scorer name
17
+ # @attr duration [Float] Duration in seconds
18
+ # @attr error_count [Integer] Number of errors
19
+ # @attr errors [Array<String>] Error messages with locations
20
+ ExperimentSummary = Struct.new(
21
+ :project_name,
22
+ :experiment_name,
23
+ :experiment_id,
24
+ :experiment_url,
25
+ :scores,
26
+ :duration,
27
+ :error_count,
28
+ :errors,
29
+ keyword_init: true
30
+ )
31
+ end
32
+ end