llm-evals 0.4.0 → 0.5.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +163 -87
  4. data/exe/llm-evals +60 -5
  5. data/lib/.DS_Store +0 -0
  6. data/lib/llm/.DS_Store +0 -0
  7. data/lib/llm/evals/actions/adapter.rb +50 -0
  8. data/lib/llm/evals/actions/error_budget.rb +63 -0
  9. data/lib/llm/evals/actions/error_diagnosis.rb +51 -0
  10. data/lib/llm/evals/actions/error_streak.rb +83 -0
  11. data/lib/llm/evals/actions/experiment.rb +251 -0
  12. data/lib/llm/evals/actions/verify.rb +61 -0
  13. data/lib/llm/evals/actions.rb +9 -0
  14. data/lib/llm/evals/cli/cases.rb +10 -0
  15. data/lib/llm/evals/cli/command.rb +32 -99
  16. data/lib/llm/evals/cli/compare.rb +0 -20
  17. data/lib/llm/evals/cli/contender_selector.rb +61 -0
  18. data/lib/llm/evals/cli/error_pause.rb +81 -0
  19. data/lib/llm/evals/cli/experiment_pool.rb +97 -0
  20. data/lib/llm/evals/cli/formatting.rb +8 -0
  21. data/lib/llm/evals/cli/leaderboard.rb +66 -12
  22. data/lib/llm/evals/cli/model_colors.rb +3 -0
  23. data/lib/llm/evals/cli/model_command.rb +152 -315
  24. data/lib/llm/evals/cli/model_options.rb +80 -0
  25. data/lib/llm/evals/cli/option_groups.rb +32 -0
  26. data/lib/llm/evals/cli/progress_bars.rb +92 -5
  27. data/lib/llm/evals/cli/render.rb +6 -6
  28. data/lib/llm/evals/cli/report_dispatcher/console.rb +65 -0
  29. data/lib/llm/evals/cli/report_dispatcher/html_output.rb +50 -0
  30. data/lib/llm/evals/cli/report_dispatcher/json_output.rb +29 -0
  31. data/lib/llm/evals/cli/report_dispatcher/output.rb +56 -0
  32. data/lib/llm/evals/cli/report_dispatcher/pipeline_output.rb +38 -0
  33. data/lib/llm/evals/cli/report_dispatcher.rb +133 -0
  34. data/lib/llm/evals/cli/run.rb +13 -22
  35. data/lib/llm/evals/cli/run_planner.rb +97 -0
  36. data/lib/llm/evals/cli/run_record.rb +68 -0
  37. data/lib/llm/evals/cli/standings.rb +39 -17
  38. data/lib/llm/evals/cli/status_box.rb +51 -0
  39. data/lib/llm/evals/cli/table.rb +103 -0
  40. data/lib/llm/evals/cli/text.rb +22 -0
  41. data/lib/llm/evals/cli/tracker_resolver.rb +52 -0
  42. data/lib/llm/evals/cli/upgrade_config.rb +60 -0
  43. data/lib/llm/evals/cli/verify.rb +5 -1
  44. data/lib/llm/evals/cli/version.rb +4 -0
  45. data/lib/llm/evals/cli/weights.rb +48 -0
  46. data/lib/llm/evals/cli.rb +39 -6
  47. data/lib/llm/evals/config/contender_builder.rb +30 -0
  48. data/lib/llm/evals/config/error_limits.rb +41 -0
  49. data/lib/llm/evals/config/listing.rb +153 -0
  50. data/lib/llm/evals/config/results.rb +42 -0
  51. data/lib/llm/evals/config/score_weights.rb +25 -0
  52. data/lib/llm/evals/config/upgrade.rb +107 -0
  53. data/lib/llm/evals/config.rb +90 -79
  54. data/lib/llm/evals/datasets/case.rb +46 -0
  55. data/lib/llm/evals/datasets/discovery.rb +143 -0
  56. data/lib/llm/evals/datasets.rb +9 -0
  57. data/lib/llm/evals/launcher.rb +57 -3
  58. data/lib/llm/evals/models/contender.rb +51 -0
  59. data/lib/llm/evals/models/model.rb +87 -0
  60. data/lib/llm/evals/models/throttle.rb +170 -0
  61. data/lib/llm/evals/models.rb +9 -0
  62. data/lib/llm/evals/providers/base.rb +61 -16
  63. data/lib/llm/evals/providers/catalog.rb +5 -2
  64. data/lib/llm/evals/providers/open_router.rb +54 -7
  65. data/lib/llm/evals/providers/registry.rb +49 -16
  66. data/lib/llm/evals/providers/type_safe/client.rb +67 -0
  67. data/lib/llm/evals/providers/type_safe.rb +113 -12
  68. data/lib/llm/evals/providers.rb +9 -0
  69. data/lib/llm/evals/render/reports/call_stats_table.rb +44 -0
  70. data/lib/llm/evals/render/reports/html/dataset_section.rb +51 -0
  71. data/lib/llm/evals/render/reports/html/format.rb +38 -0
  72. data/lib/llm/evals/render/reports/html/global_standings.rb +41 -0
  73. data/lib/llm/evals/render/reports/html/ranking.rb +86 -0
  74. data/lib/llm/evals/render/reports/html/view_model.rb +116 -0
  75. data/lib/llm/evals/render/reports/html.rb +60 -181
  76. data/lib/llm/evals/render/reports/json/decoder.rb +137 -0
  77. data/lib/llm/evals/render/reports/json/encoder.rb +106 -0
  78. data/lib/llm/evals/render/reports/json.rb +38 -141
  79. data/lib/llm/evals/render/reports/pipeline.rb +55 -20
  80. data/lib/llm/evals/render/reports/terminal.rb +8 -1
  81. data/lib/llm/evals/render.rb +9 -0
  82. data/lib/llm/evals/results/provider_response.rb +25 -0
  83. data/lib/llm/evals/results/reply.rb +38 -0
  84. data/lib/llm/evals/results/run_outcome.rb +15 -0
  85. data/lib/llm/evals/results/run_result.rb +40 -0
  86. data/lib/llm/evals/results/score.rb +45 -0
  87. data/lib/llm/evals/results/warnings.rb +149 -0
  88. data/lib/llm/evals/results.rb +9 -0
  89. data/lib/llm/evals/runtime/configuration.rb +295 -0
  90. data/lib/llm/evals/runtime/environment.rb +90 -0
  91. data/lib/llm/evals/runtime/helper.rb +153 -0
  92. data/lib/llm/evals/runtime.rb +2 -67
  93. data/lib/llm/evals/scoring/metric.rb +47 -0
  94. data/lib/llm/evals/scoring/schema_validator.rb +102 -0
  95. data/lib/llm/evals/scoring/scorer.rb +49 -0
  96. data/lib/llm/evals/scoring/weighted_score.rb +148 -0
  97. data/lib/llm/evals/scoring.rb +9 -0
  98. data/lib/llm/evals/stats/call_stats.rb +91 -0
  99. data/lib/llm/evals/stats/usage.rb +46 -0
  100. data/lib/llm/evals/stats.rb +29 -0
  101. data/lib/llm/evals/testing/echo_adapter.rb +14 -9
  102. data/lib/llm/evals/testing/fake_model.rb +8 -6
  103. data/lib/llm/evals/testing/fake_provider.rb +39 -8
  104. data/lib/llm/evals/testing.rb +9 -0
  105. data/lib/llm/evals/trackers/base.rb +110 -0
  106. data/lib/llm/evals/trackers/braintrust.rb +40 -3
  107. data/lib/llm/evals/trackers/local.rb +38 -2
  108. data/lib/llm/evals/trackers/logfire.rb +56 -3
  109. data/lib/llm/evals/trackers/registry.rb +29 -7
  110. data/lib/llm/evals/trackers.rb +9 -0
  111. data/lib/llm/evals/version.rb +3 -1
  112. data/lib/llm/evals.rb +17 -7
  113. metadata +91 -24
  114. data/lib/llm/evals/adapter.rb +0 -47
  115. data/lib/llm/evals/case.rb +0 -36
  116. data/lib/llm/evals/completion.rb +0 -11
  117. data/lib/llm/evals/configuration.rb +0 -173
  118. data/lib/llm/evals/contender.rb +0 -31
  119. data/lib/llm/evals/discovery.rb +0 -95
  120. data/lib/llm/evals/experiment.rb +0 -158
  121. data/lib/llm/evals/helper.rb +0 -98
  122. data/lib/llm/evals/metric.rb +0 -26
  123. data/lib/llm/evals/model.rb +0 -55
  124. data/lib/llm/evals/reply.rb +0 -18
  125. data/lib/llm/evals/run_result.rb +0 -14
  126. data/lib/llm/evals/schema_validator.rb +0 -84
  127. data/lib/llm/evals/score.rb +0 -26
  128. data/lib/llm/evals/scorer.rb +0 -45
  129. data/lib/llm/evals/tracker.rb +0 -80
  130. data/lib/llm/evals/usage.rb +0 -33
  131. data/lib/llm/evals/verify.rb +0 -49
  132. data/lib/llm/evals/warnings.rb +0 -98
  133. data/lib/llm/evals/weighted_score.rb +0 -116
  134. /data/lib/llm/evals/{templates → render/templates}/html_report.css +0 -0
  135. /data/lib/llm/evals/{templates → render/templates}/html_report.html.erb +0 -0
  136. /data/lib/llm/evals/{templates → render/templates}/html_report.js +0 -0
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # Offline consistency checks over every case, so a typo in the suite is
6
- # caught before it is billed as a model's mistake. The gem checks what
7
- # every suite has: an input and a parsable expected answer. The adapter
8
- # checks each dataset's shared files, and the scorer each case against
9
- # what it will demand of it.
10
- class Verify
11
- # @param configuration [Configuration]
12
- def initialize(configuration = LLM::Evals.configuration)
13
- @configuration = configuration
14
- end
15
-
16
- # @return [Hash{String => Array<String>}] problems by "dataset/id"; empty when all is sound
17
- def problems
18
- discovery = @configuration.discovery
19
- failures = {}
20
- discovery.datasets.each do |dataset|
21
- Array(adapter.verify(dataset)).each { (failures[dataset.to_s] ||= []) << it }
22
- discovery.cases(dataset: dataset).each do |kase|
23
- list = case_problems(kase)
24
- failures[kase.key] = list unless list.empty?
25
- end
26
- end
27
- failures
28
- end
29
-
30
- # @param kase [Case]
31
- # @return [Array<String>]
32
- def case_problems(kase)
33
- problems = []
34
- problems << "input is empty" if kase.input.empty?
35
- problems << kase.metadata[:expected_error] if kase.metadata[:expected_error]
36
- return problems unless kase.expected?
37
-
38
- problems.concat(Array(scorer.verify(kase, context: adapter.context(kase))))
39
- rescue StandardError => e
40
- problems << "#{e.class}: #{e.message}"
41
- end
42
-
43
- private
44
-
45
- def adapter = @configuration.adapter
46
- def scorer = @configuration.scorer
47
- end
48
- end
49
- end
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "logger"
4
-
5
- module LLM
6
- module Evals
7
- # Everything a run would otherwise print as a warning while its progress
8
- # bars redraw: cases that failed, and what Braintrust, RubyLLM, OpenTelemetry
9
- # (whose exporters report failures on $stdout) and Ruby itself log. Printed among the bars, each one breaks the redraw; collected
10
- # here, they are saved to one file, and the run prints only how many.
11
- #
12
- # It quacks as an IO, so a Logger, and $stderr, can write to it. Threads
13
- # share it.
14
- class Warnings
15
- def initialize
16
- @lines = []
17
- @lock = Mutex.new
18
- end
19
-
20
- # @param text [#to_s]
21
- # @return [self]
22
- def record(text)
23
- text = text.to_s.chomp
24
- @lock.synchronize { @lines << text } unless text.strip.empty?
25
- self
26
- end
27
-
28
- # @return [Integer] the bytes "written", as IO#write does
29
- def write(*texts)
30
- texts.each { record(it) }
31
- texts.sum { it.to_s.bytesize }
32
- end
33
-
34
- def puts(*texts) = texts.each { record(it) } && nil
35
- def print(*texts) = write(*texts) && nil
36
- def <<(text) = record(text)
37
- def flush = self
38
- def sync = true
39
- def sync=(_value); end
40
- def close = nil
41
- def tty? = false
42
-
43
- # @return [Integer]
44
- def count = @lock.synchronize { @lines.size }
45
-
46
- # @return [Array<String>]
47
- def to_a = @lock.synchronize { @lines.dup }
48
-
49
- # @return [Boolean] whether nothing was recorded
50
- def none? = count.zero?
51
-
52
- # @param path [String]
53
- # @return [String] the path
54
- def save(path)
55
- File.write(path, "#{to_a.join("\n\n")}\n")
56
- path
57
- end
58
-
59
- # Runs the block with $stderr, Ruby's own warnings, and the loggers of
60
- # whichever optional gems are loaded writing here, and puts them back after.
61
- #
62
- # @return [Object] what the block returns
63
- def capture
64
- logger = Logger.new(self, level: Logger::WARN)
65
- stderr = $stderr
66
- kernel_warn = Kernel.instance_method(:warn)
67
- warnings = self
68
- restore = redirect_loggers(logger)
69
- $stderr = self
70
- Kernel.define_method(:warn) { |*messages, **_options| warnings.write(messages.join("\n")) }
71
- Kernel.send(:private, :warn)
72
- yield
73
- ensure
74
- Kernel.define_method(:warn, kernel_warn) if kernel_warn
75
- Kernel.send(:private, :warn)
76
- restore&.call
77
- $stderr = stderr
78
- end
79
-
80
- private
81
-
82
- # @return [Proc] puts the loggers back
83
- def redirect_loggers(logger)
84
- braintrust = defined?(Braintrust::Log) ? Braintrust::Log.logger : nil
85
- ruby_llm = defined?(RubyLLM) ? RubyLLM.instance_variable_get(:@logger) : nil
86
- open_telemetry = defined?(OpenTelemetry) ? OpenTelemetry.logger : nil
87
- Braintrust::Log.logger = logger if defined?(Braintrust::Log)
88
- RubyLLM.instance_variable_set(:@logger, logger) if defined?(RubyLLM)
89
- OpenTelemetry.logger = logger if defined?(OpenTelemetry)
90
- lambda do
91
- Braintrust::Log.logger = braintrust if defined?(Braintrust::Log)
92
- RubyLLM.instance_variable_set(:@logger, ruby_llm) if defined?(RubyLLM)
93
- OpenTelemetry.logger = open_telemetry if defined?(OpenTelemetry)
94
- end
95
- end
96
- end
97
- end
98
- end
@@ -1,116 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # One number from the leaderboard's columns, weighted as the lineup file
6
- # says, for a suite to state what matters most to it. A positive weight
7
- # means higher is better; a negative one, lower is better:
8
- #
9
- # weighted_score:
10
- # weights:
11
- # score: 0.4
12
- # hallucinated_rate: -0.2
13
- # cost: -0.2
14
- #
15
- # weighted_score = Σ(|weight| × goodness) / Σ|weight|
16
- #
17
- # Goodness runs from 0 to 1. Metrics are used as they are, or as 1 - value
18
- # under a negative weight. {MAGNITUDES}
19
- # are placed on a log scale between the lowest and highest in the group, so
20
- # that each doubling costs the same: on a linear scale, one model a hundred
21
- # times the price would squash all the others together. Where the whole
22
- # group is equal, each gets 1.
23
- #
24
- # Errors come off the total, when weighted: 20 points for one, 25 for two, 30
25
- # for three, and so on, down to 0. A failed case has no scores, so no other
26
- # column notices it, and a single failure must show. A clean contender gets
27
- # the whole of the weight on errors.
28
- #
29
- # Magnitudes are relative, so a weighted score compares within one group
30
- # only: another model in the lineup moves everyone's.
31
- class WeightedScore
32
- # Columns compared on a log scale.
33
- MAGNITUDES = %w[time cost].freeze
34
-
35
- # The gem's columns that can be weighted, beside the metrics. `temp` is a
36
- # setting, not a result, and tokens are already in the cost.
37
- COLUMNS = ["score", "errors", *MAGNITUDES].freeze
38
-
39
- # What errors take off the total: {ERROR_BASE} + {ERROR_STEP} per error.
40
- ERROR_BASE = 0.15
41
- ERROR_STEP = 0.05
42
-
43
- attr_reader :weights
44
-
45
- # @param weights [Hash{String => Numeric}]
46
- # @param metrics [Array<String>] the scorer's metric names
47
- # @raise [Error] on a weight that is not a number, an unknown column, or no weight at all
48
- def initialize(weights, metrics:)
49
- known = (metrics + COLUMNS).uniq
50
- unknown = weights.keys - known
51
- raise Error, "weighted_score: unknown columns #{unknown.join(", ")}; known: #{known.join(", ")}" if unknown.any?
52
-
53
- invalid = weights.reject { |_name, weight| weight.is_a?(Numeric) }.keys
54
- raise Error, "weighted_score: weights must be numbers: #{invalid.join(", ")}" if invalid.any?
55
-
56
- raise Error, "weighted_score: errors must have a negative weight, since fewer is better" if weights.fetch("errors", -1).positive?
57
-
58
- @weights = weights.reject { |_name, weight| weight.zero? }
59
- raise Error, "weighted_score: every weight is zero" if @weights.empty?
60
- end
61
-
62
- # A column no row has does not count. A row missing a column the others
63
- # have gets nil: it was not measured on everything they were.
64
- #
65
- # @param rows [Array<Hash{String => Numeric, nil}>] one per contender, compared with each other
66
- # @return [Array<Float, nil>] in the order given
67
- def call(rows)
68
- present = weights.select { |name, _weight| rows.any? { it[name] } }
69
- total = present.values.sum(&:abs)
70
- rows.map do |row|
71
- next if present.empty? || present.keys.any? { row[it].nil? }
72
-
73
- score = present.sum { |name, weight| weight.abs * goodness(name, row[name], weight, rows) } / total
74
- [score - penalty(row["errors"], present), 0.0].max
75
- end
76
- end
77
-
78
- # @return [String] the formula, with this lineup's weights, for the glossary
79
- def description
80
- "Σ(|weight| × goodness) / Σ|weight|, from 0% to 100%, and higher is better. A negative weight means that column is better low. " \
81
- "Errors come off the total: 20 points for one, then 5 more for each further error. " \
82
- "#{MAGNITUDES.join(", ")} are on a log scale within the group, so compare wscore within one table only. " \
83
- "Weights: #{weights.map { |name, weight| "#{name} #{weight}" }.join(", ")}."
84
- end
85
-
86
- private
87
-
88
- def penalty(errors, present)
89
- return 0.0 unless present.key?("errors") && errors.positive?
90
-
91
- ERROR_BASE + (ERROR_STEP * errors)
92
- end
93
-
94
- def goodness(name, value, weight, rows)
95
- return 1.0 if name == "errors"
96
-
97
- position = MAGNITUDES.include?(name) ? position(value, rows.filter_map { it[name] }) : value.to_f
98
- return 1.0 if position.nil?
99
-
100
- weight.negative? ? 1.0 - position : position
101
- end
102
-
103
- # A zero has no logarithm, so it stands at half the lowest above zero.
104
- #
105
- # @return [Float, nil] from 0 at the lowest to 1 at the highest; nil when all are equal
106
- def position(value, values)
107
- highest = values.max
108
- return nil if values.min == highest
109
-
110
- lowest = values.select(&:positive?).min
111
- lowest /= 2.0 if values.any?(&:zero?)
112
- (Math.log([value, lowest].max) - Math.log(lowest)) / (Math.log(highest) - Math.log(lowest))
113
- end
114
- end
115
- end
116
- end