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,74 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "dotenv"
4
-
5
3
  module LLM
6
4
  module Evals
7
- # One-time process setup: secrets from the suite's env files, a check that
8
- # every key and gem the run needs is there, the suite's own hooks, and
9
- # the providers' clients.
10
- class Runtime
11
- attr_reader :env_files, :hooks, :app_name
12
-
13
- # @param env_files [Array<String>] absolute paths; those that exist load
14
- # @param hooks [Array<Proc>] the suite's `before_run` blocks
15
- # @param app_name [String] what a provider tells its API the caller is
16
- def initialize(env_files: [], hooks: [], app_name: "llm-evals")
17
- @env_files = env_files
18
- @hooks = hooks
19
- @app_name = app_name
20
- end
21
-
22
- # Loads the env files that exist, without overriding what is already
23
- # set, so an exported variable beats the file.
24
- #
25
- # @return [void]
26
- def load_env
27
- files = env_files.select { File.exist?(it) }
28
- Dotenv.load(*files) unless files.empty?
29
- end
30
-
31
- # Fails before anything is called when a key the run needs is neither
32
- # exported nor in an env file.
33
- #
34
- # @param names [Array<String>]
35
- # @return [void]
36
- # @raise [Error] naming every missing variable
37
- def require_env!(names)
38
- missing = names.uniq.select { ENV[it].to_s.strip.empty? }
39
- return if missing.empty?
40
-
41
- found = env_files.select { File.exist?(it) }
42
- where = if found.empty?
43
- env_files.empty? ? "no env files are configured" : "there is no #{env_files.join(" or ")}"
44
- else
45
- "checked the environment, then #{found.join(", ")}"
46
- end
47
- raise Error,
48
- "missing #{missing.join(", ")}: export them or add them to an env file (#{where}); " \
49
- "an exported blank value wins over the file"
50
- end
51
-
52
- # @param providers [Array<Providers::Base>] the providers the run's models use
53
- # @param tracker [Class<Tracker>, nil] the remote tracker chosen, whose gems and key are checked too
54
- # @return [void]
55
- # @raise [Error] when a needed variable or gem is missing
56
- def setup!(providers: [], tracker: nil)
57
- load_env
58
- require_env!(providers.flat_map(&:env_keys))
59
- tracker&.requirements!(ENV)
60
- hooks.each(&:call) # after load_env: a hook usually re-reads ENV
61
- providers.each { it.setup!(app_name: app_name) }
62
- end
63
-
64
- # Opens a URL in the desktop browser.
65
- #
66
- # @param url [String]
67
- # @return [Boolean] whether the opener ran
68
- def browse(url)
69
- opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
70
- system(opener, url, out: File::NULL, err: File::NULL) || false
71
- end
5
+ # How a suite is loaded and set up: its settings, its eval helper, its environment.
6
+ module Runtime
72
7
  end
73
8
  end
74
9
  end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Scoring
6
+ # One number a {Scorer} reports per case. `aggregate` is how the run
7
+ # combines them (only :mean today), skipping nil, so a metric a case does
8
+ # not apply to never drags its mean down with a free 0 or up with a free 1.
9
+ # The `primary` metric ranks the leaderboard and picks the best value.
10
+ #
11
+ # @!attribute [r] name
12
+ # @return [String] the column's key
13
+ # @!attribute [r] description
14
+ # @return [String] the glossary's line
15
+ # @!attribute [r] aggregate
16
+ # @return [Symbol] one of {AGGREGATES}
17
+ # @!attribute [r] primary
18
+ # @return [Boolean] whether it ranks the leaderboard
19
+ class Metric < Data.define(:name, :description, :aggregate, :primary)
20
+ # The ways {#combine} can be asked to combine values.
21
+ AGGREGATES = %i[mean].freeze
22
+
23
+ # Turns the name into a String, so a metric can be declared with a Symbol.
24
+ #
25
+ # @param name [String, Symbol] the column's key
26
+ # @param description [String] the glossary's line
27
+ # @param aggregate [Symbol] one of {AGGREGATES}
28
+ # @param primary [Boolean] whether it ranks the leaderboard
29
+ # @raise [ArgumentError] on an aggregate outside {AGGREGATES}
30
+ def initialize(name:, description: "", aggregate: :mean, primary: false)
31
+ raise ArgumentError, "aggregate must be one of #{AGGREGATES.join(", ")}" unless AGGREGATES.include?(aggregate)
32
+
33
+ super(name: name.to_s, description: description, aggregate: aggregate, primary: primary)
34
+ end
35
+
36
+ # Averages what the cases reported, skipping nil.
37
+ #
38
+ # @param values [Array<Float, nil>] one per case
39
+ # @return [Float, nil] nil when no case reported the metric
40
+ def combine(values)
41
+ present = values.compact
42
+ present.sum.fdiv(present.size) unless present.empty?
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Scoring
6
+ # Checks a Hash of answers against a field map, for scorers and verifiers
7
+ # whose expected values are typed and closed. Nothing here is specific to
8
+ # what the fields mean.
9
+ #
10
+ # The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
11
+ # {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
12
+ # nullable field accepts nil (the text never answered it).
13
+ class SchemaValidator
14
+ # The types a field may declare.
15
+ TYPES = %i[boolean integer number string enum multi_enum].freeze
16
+
17
+ # The map as given, with String names, and each spec normalized to type, enum and nullable.
18
+ #
19
+ # @return [Hash{String => Hash{Symbol => Object}}]
20
+ attr_reader :fields
21
+
22
+ # @param fields [Hash{String, Symbol => Hash}]
23
+ # @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
24
+ def initialize(fields)
25
+ @fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
26
+ end
27
+
28
+ # @return [Array<String>] the field names
29
+ def names = fields.keys
30
+
31
+ # @param values [Hash] String or Symbol keys
32
+ # @return [Array<String>] every problem found; empty when the values fit the map
33
+ def problems(values)
34
+ return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
35
+
36
+ actual = values.transform_keys(&:to_s)
37
+ missing = names - actual.keys
38
+ extra = actual.keys - names
39
+ problems = []
40
+ problems << "missing #{missing.join(", ")}" if missing.any?
41
+ problems << "unknown fields #{extra.join(", ")}" if extra.any?
42
+ fields.each do |name, spec|
43
+ next unless actual.key?(name)
44
+
45
+ problem = value_problem(spec, actual[name])
46
+ problems << "#{name}: #{problem}" if problem
47
+ end
48
+ problems
49
+ end
50
+
51
+ # @param values [Hash] String or Symbol keys
52
+ # @return [Boolean] whether {#problems} finds none
53
+ def valid?(values) = problems(values).empty?
54
+
55
+ # @param spec [Hash] one field's normalized spec
56
+ # @param value [Object]
57
+ # @return [String, nil] what is wrong with the value, or nil
58
+ def value_problem(spec, value)
59
+ return (spec[:nullable] ? nil : "is null") if value.nil?
60
+
61
+ case spec[:type]
62
+ when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
63
+ when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
64
+ when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
65
+ when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
66
+ when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
67
+ when :multi_enum then multi_enum_problem(spec, value)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ # Checks that a multi_enum is a list holding only allowed values.
74
+ #
75
+ # @param spec [Hash] one field's normalized spec
76
+ # @param value [Object] the field's non-nil value
77
+ # @return [String, nil] what is wrong with the value, or nil
78
+ def multi_enum_problem(spec, value)
79
+ return "#{value.inspect} is not an array" unless value.is_a?(Array)
80
+
81
+ stray = value - spec[:enum]
82
+ "#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
83
+ end
84
+
85
+ # Accepts `values:` as a synonym for `enum:`, and makes allowed values Strings.
86
+ #
87
+ # @param name [String, Symbol] the field, for the error
88
+ # @param spec [Hash] the field's spec, with String or Symbol keys
89
+ # @return [Hash{Symbol => Object}] with type, enum and nullable
90
+ # @raise [ArgumentError] on a type outside {TYPES}
91
+ # @raise [KeyError] when the spec has no type
92
+ def normalize(name, spec)
93
+ spec = spec.transform_keys(&:to_sym)
94
+ type = spec.fetch(:type).to_sym
95
+ raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
96
+
97
+ { type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Scoring
6
+ # What a suite implements to score one reply. Pure and deterministic: it
7
+ # reads the case, the reply and the adapter's context, and calls nothing.
8
+ #
9
+ # @abstract Subclass and override {#metrics} and {#call}.
10
+ class Scorer
11
+ # The metrics every {Results::Score} this scorer returns carries. The CLI builds
12
+ # its columns, its glossary and its Braintrust scorers from this list,
13
+ # so a metric missing here is a metric nobody sees.
14
+ #
15
+ # @return [Array<Metric>]
16
+ # @raise [NotImplementedError] until a subclass overrides it
17
+ def metrics
18
+ raise NotImplementedError, "#{self.class}#metrics must return an Array of LLM::Evals::Scoring::Metric"
19
+ end
20
+
21
+ # @return [Metric] the one flagged primary, else the first
22
+ def primary = metrics.find(&:primary) || metrics.first
23
+
24
+ # @param kase [Datasets::Case]
25
+ # @param reply [Results::Reply] never one with `error` set; the runner scores those 0 itself
26
+ # @param context [Hash] what {Actions::Adapter#context} returned for the case
27
+ # @return [Results::Score]
28
+ # @raise [NotImplementedError] until a subclass overrides it
29
+ def call(kase, reply, context:)
30
+ raise NotImplementedError, "#{self.class}#call(kase, reply, context:) must return an LLM::Evals::Results::Score"
31
+ end
32
+
33
+ # Offline checks of one case against what the scorer will demand of it,
34
+ # so a typo in expected.json is caught before it is billed as a model's mistake.
35
+ #
36
+ # @param kase [Datasets::Case]
37
+ # @param context [Hash] what {Actions::Adapter#context} returned for the case
38
+ # @return [Array<String>] problems; empty when the case is sound
39
+ def verify(kase, context:) = [] # rubocop:disable Lint/UnusedMethodArgument -- the signature is the contract
40
+
41
+ # One short cell describing the case in `llm-evals cases`, e.g. "5/8 answered".
42
+ #
43
+ # @param _kase [Datasets::Case] the case to describe
44
+ # @return [String, nil] nil for the generic fallback
45
+ def summary(_kase) = nil
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Scoring
6
+ # One number from the leaderboard's columns, weighted as the lineup file
7
+ # says, for a suite to state what matters most to it. A positive weight
8
+ # means higher is better; a negative one, lower is better:
9
+ #
10
+ # weighted_score:
11
+ # weights:
12
+ # score: 0.4
13
+ # hallucinated_rate: -0.2
14
+ # cost: -0.2
15
+ #
16
+ # weighted_score = Σ(|weight| × goodness) / Σ|weight|
17
+ #
18
+ # Goodness runs from 0 to 1. Metrics are used as they are, or as 1 - value
19
+ # under a negative weight. {MAGNITUDES}
20
+ # are placed on a log scale between the lowest and highest in the group, so
21
+ # that each doubling costs the same: on a linear scale, one model a hundred
22
+ # times the price would squash all the others together. Where the whole
23
+ # group is equal, each gets 1.
24
+ #
25
+ # Errors come off the total, when weighted: 20 points for one, 25 for two, 30
26
+ # for three, and so on, down to 0. A failed case has no scores, so no other
27
+ # column notices it, and a single failure must show. A clean contender gets
28
+ # the whole of the weight on errors.
29
+ #
30
+ # Magnitudes are relative, so a weighted score compares within one group
31
+ # only: another model in the lineup moves everyone's.
32
+ class WeightedScore
33
+ # Columns compared on a log scale.
34
+ MAGNITUDES = %w[time cost latency p95].freeze
35
+
36
+ # Columns that add up over a contender's datasets; the rest average, which
37
+ # for p95 is an approximation good enough to rank by.
38
+ SUMMED = %w[errors time cost].freeze
39
+
40
+ # The gem's columns that can be weighted, beside the metrics. `temp` is a
41
+ # setting, not a result, and tokens are already in the cost.
42
+ COLUMNS = ["score", "errors", *MAGNITUDES].freeze
43
+
44
+ # What errors take off the total: {ERROR_BASE} + {ERROR_STEP} per error.
45
+ ERROR_BASE = 0.15
46
+ # The second part of the errors' penalty; see {ERROR_BASE}.
47
+ ERROR_STEP = 0.05
48
+
49
+ # The weights as the lineup gives them, without those that are zero.
50
+ #
51
+ # @return [Hash{String => Numeric}] by column
52
+ attr_reader :weights
53
+
54
+ # @param weights [Hash{String => Numeric}]
55
+ # @param metrics [Array<String>] the scorer's metric names
56
+ # @raise [Error] on a weight that is not a number, an unknown column, or no weight at all
57
+ def initialize(weights, metrics:)
58
+ known = (metrics + COLUMNS).uniq
59
+ unknown = weights.keys - known
60
+ raise Error, "weighted_score: unknown columns #{unknown.join(", ")}; known: #{known.join(", ")}" if unknown.any?
61
+
62
+ invalid = weights.reject { |_name, weight| weight.is_a?(Numeric) }.keys
63
+ raise Error, "weighted_score: weights must be numbers: #{invalid.join(", ")}" if invalid.any?
64
+
65
+ raise Error, "weighted_score: errors must have a negative weight, since fewer is better" if weights.fetch("errors", -1).positive?
66
+
67
+ @weights = weights.reject { |_name, weight| weight.zero? }
68
+ raise Error, "weighted_score: every weight is zero" if @weights.empty?
69
+ end
70
+
71
+ # A column no row has does not count. A row missing a column the others
72
+ # have gets nil: it was not measured on everything they were.
73
+ #
74
+ # @param rows [Array<Hash{String => Numeric, nil}>] one per contender, compared with each other
75
+ # @return [Array<Float, nil>] in the order given
76
+ def call(rows)
77
+ present = weights.select { |name, _weight| rows.any? { it[name] } }
78
+ total = present.values.sum(&:abs)
79
+ rows.map do |row|
80
+ next if present.empty? || present.keys.any? { row[it].nil? }
81
+
82
+ score = present.sum { |name, weight| weight.abs * goodness(name, row[name], weight, rows) } / total
83
+ [score - penalty(row["errors"], present), 0.0].max
84
+ end
85
+ end
86
+
87
+ # What each weight really counts for, since weights need not add up to 1.
88
+ #
89
+ # @return [Hash{String => Float}] by column, each from 0 to 1, largest first
90
+ def shares
91
+ total = weights.values.sum(&:abs).to_f
92
+ weights.sort_by { |_name, weight| -weight.abs }.to_h { |name, weight| [name, weight.abs / total] }
93
+ end
94
+
95
+ # @return [String] the formula, with this lineup's weights, for the glossary
96
+ def description
97
+ "Σ(|weight| × goodness) / Σ|weight|, from 0% to 100%, and higher is better. A negative weight means that column is better low. " \
98
+ "Errors come off the total: 20 points for one, then 5 more for each further error. " \
99
+ "#{MAGNITUDES.join(", ")} are on a log scale within the group, so compare wscore within one table only. " \
100
+ "Weights: #{weights.map { |name, weight| "#{name} #{weight}" }.join(", ")}."
101
+ end
102
+
103
+ private
104
+
105
+ # What errors take off the total, when errors carry a weight.
106
+ #
107
+ # @param errors [Numeric, nil] the row's error count; only nil when errors are not weighted
108
+ # @param present [Hash{String => Numeric}] the weights of the columns some row has
109
+ # @return [Float] 0.0 for a clean row
110
+ def penalty(errors, present)
111
+ return 0.0 unless present.key?("errors") && errors.positive?
112
+
113
+ ERROR_BASE + (ERROR_STEP * errors)
114
+ end
115
+
116
+ # How good one value is, from 0 to 1, with its weight's sign applied.
117
+ #
118
+ # @param name [String] the column
119
+ # @param value [Numeric] the row's value
120
+ # @param weight [Numeric] the column's weight, not zero
121
+ # @param rows [Array<Hash{String => Numeric, nil}>] the group it is compared with
122
+ # @return [Float] 1.0 for errors, which {#penalty} accounts for, and where the group is equal
123
+ def goodness(name, value, weight, rows)
124
+ return 1.0 if name == "errors"
125
+
126
+ position = MAGNITUDES.include?(name) ? position(value, rows.filter_map { it[name] }) : value.to_f
127
+ return 1.0 if position.nil?
128
+
129
+ weight.negative? ? 1.0 - position : position
130
+ end
131
+
132
+ # A zero has no logarithm, so it stands at half the lowest above zero.
133
+ #
134
+ # @param value [Numeric] the value to place
135
+ # @param values [Array<Numeric>] the group's values, the value among them
136
+ # @return [Float, nil] from 0 at the lowest to 1 at the highest; nil when all are equal
137
+ def position(value, values)
138
+ highest = values.max
139
+ return nil if values.min == highest
140
+
141
+ lowest = values.select(&:positive?).min
142
+ lowest /= 2.0 if values.any?(&:zero?)
143
+ (Math.log([value, lowest].max) - Math.log(lowest)) / (Math.log(highest) - Math.log(lowest))
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # How a reply is judged: the suite's scorer, its metrics, and how columns are weighted.
6
+ module Scoring
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Stats
6
+ # How many calls a run made and how fast they came back, per provider, per
7
+ # vendor behind it, and per model at every setting. Latency counts only
8
+ # calls that worked, since a refusal returns quickly and would flatter the
9
+ # slowest model. Requests per second are over the whole run, so the rows
10
+ # compare with each other.
11
+ class CallStats
12
+ # The share {Stats.percentile} is asked for, shared with {Actions::Experiment::Summary#latency_p95}.
13
+ P95 = 0.95
14
+
15
+ # One line of the report: a provider, a vendor behind it, or one model at one setting.
16
+ #
17
+ # @!attribute [r] scope
18
+ # @return [Symbol] :provider, :vendor or :model
19
+ # @!attribute [r] name
20
+ # @return [String] the provider's name, or "provider/vendor" or "provider/model"
21
+ # @!attribute [r] requests
22
+ # @return [Integer] every call made, including failures
23
+ # @!attribute [r] errors
24
+ # @return [Integer] calls that raised or that the model failed
25
+ # @!attribute [r] per_second
26
+ # @return [Float, nil] over the whole run; nil when its length is unknown or zero
27
+ # @!attribute [r] latency
28
+ # @return [Float, nil] mean seconds of the calls that worked; nil with none
29
+ # @!attribute [r] p95
30
+ # @return [Float, nil] 95th percentile seconds of the calls that worked; nil with none
31
+ Row = Data.define(:scope, :name, :requests, :errors, :per_second, :latency, :p95)
32
+
33
+ # Reads the run's length out of its recorded options, however they were stored.
34
+ #
35
+ # @param summaries [Array<Actions::Experiment::Summary>]
36
+ # @param run_options [Hash] as a run recorded them, with symbol keys or, read back from JSON, string keys
37
+ # @return [CallStats]
38
+ def self.for_run(summaries, run_options)
39
+ new(summaries, seconds: run_options.dig(:effective, :seconds) || run_options.dig("effective", "seconds"))
40
+ end
41
+
42
+ # Computes nothing until {#rows}.
43
+ #
44
+ # @param summaries [Array<Actions::Experiment::Summary>]
45
+ # @param seconds [Numeric, nil] how long the run took; nil leaves requests per second empty
46
+ def initialize(summaries, seconds:)
47
+ @summaries = summaries
48
+ @seconds = seconds
49
+ end
50
+
51
+ # A direct provider has no vendor rows, since it serves only its own models.
52
+ #
53
+ # @return [Array<Row>] each provider, then its vendors, each followed by its models
54
+ def rows
55
+ @summaries.group_by { it.model.provider }.flat_map do |provider, by_provider|
56
+ [row(:provider, provider.name, by_provider)] + by_provider.group_by { vendor(it) }.flat_map do |name, by_vendor|
57
+ models = by_vendor.group_by { it.model.to_s }.map { |model, group| row(:model, "#{provider.name}/#{model}", group) }
58
+ provider.direct? ? models : [row(:vendor, "#{provider.name}/#{name}", by_vendor), *models]
59
+ end
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ # The vendor is the id's first segment, "anthropic" in "anthropic/claude-haiku-5.5".
66
+ #
67
+ # @param summary [Actions::Experiment::Summary]
68
+ # @return [String]
69
+ def vendor(summary) = summary.model.id.split("/").first
70
+
71
+ # Totals one group of summaries into a row.
72
+ #
73
+ # @param scope [Symbol] :provider, :vendor or :model
74
+ # @param name [String] the row's label
75
+ # @param summaries [Array<Actions::Experiment::Summary>] the group
76
+ # @return [Row]
77
+ def row(scope, name, summaries)
78
+ results = summaries.flat_map(&:results)
79
+ worked = summaries.flat_map(&:worked_durations)
80
+ Row.new(scope:,
81
+ name:,
82
+ requests: results.size,
83
+ errors: results.count(&:errored?),
84
+ per_second: @seconds&.positive? ? results.size.fdiv(@seconds) : nil,
85
+ latency: Stats.mean(worked),
86
+ p95: Stats.percentile(worked, P95))
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Stats
6
+ # What one call cost, as the provider counted it. Summed over a run.
7
+ #
8
+ # @!attribute [r] input_tokens
9
+ # @return [Integer]
10
+ # @!attribute [r] output_tokens
11
+ # @return [Integer]
12
+ # @!attribute [r] cost_usd
13
+ # @return [Float] in US dollars
14
+ Usage = Data.define(:input_tokens, :output_tokens, :cost_usd) do
15
+ # The start of a sum, and the usage of a reply that never reached a provider.
16
+ #
17
+ # @return [Usage] zero of everything
18
+ def self.zero = new(input_tokens: 0, output_tokens: 0, cost_usd: 0.0)
19
+
20
+ # Reads a Hash with Symbol or String keys, missing keys as zero.
21
+ #
22
+ # @param hash [Hash, Usage, nil]
23
+ # @return [Usage]
24
+ def self.from(hash)
25
+ return hash if hash.is_a?(Usage)
26
+ return zero if hash.nil?
27
+
28
+ h = hash.transform_keys(&:to_sym)
29
+ new(input_tokens: h.fetch(:input_tokens, 0).to_i,
30
+ output_tokens: h.fetch(:output_tokens, 0).to_i,
31
+ cost_usd: h.fetch(:cost_usd, 0.0).to_f)
32
+ end
33
+
34
+ # Adds field by field, which is how a run's usage is totalled.
35
+ #
36
+ # @param other [Usage]
37
+ # @return [Usage]
38
+ def +(other)
39
+ with(input_tokens: input_tokens + other.input_tokens,
40
+ output_tokens: output_tokens + other.output_tokens,
41
+ cost_usd: cost_usd + other.cost_usd)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # The arithmetic reports and summaries share.
6
+ module Stats
7
+ module_function
8
+
9
+ # Nearest-rank, so the value is one a call really took.
10
+ #
11
+ # @param values [Array<Numeric>]
12
+ # @param share [Float] from 0 to 1
13
+ # @return [Numeric, nil] nil with no values
14
+ # @example
15
+ # Stats.percentile([1, 2, 3, 4], 0.5) # => 2
16
+ def percentile(values, share)
17
+ return if values.empty?
18
+
19
+ values.sort[[(share * values.size).ceil - 1, 0].max]
20
+ end
21
+
22
+ # Plain average, sparing callers a division by zero on an empty list.
23
+ #
24
+ # @param values [Array<Numeric>]
25
+ # @return [Float, nil] nil with no values
26
+ def mean(values) = values.empty? ? nil : values.sum.fdiv(values.size)
27
+ end
28
+ end
29
+ end
@@ -7,21 +7,26 @@ module LLM
7
7
  # prompt, parses the reply as a JSON object, and reports a reply that
8
8
  # is not one as the model's failure. The toy suite runs on it, and a
9
9
  # new suite can start from it.
10
- class EchoAdapter < Adapter
10
+ class EchoAdapter < Actions::Adapter
11
+ # The system prompt sent with every case.
11
12
  SYSTEM = "Answer with a single JSON object and nothing else."
13
+ # Matches the Markdown code fences some models wrap JSON in, for stripping.
12
14
  FENCE = /\A```(?:json)?\s*|\s*```\z/
13
15
 
14
- # @param kase [Case]
15
- # @param model [Model]
16
- # @return [Reply]
16
+ # Overrides {Actions::Adapter#call}. Failures to parse become the reply's
17
+ # error; transport errors raise, as they do from {Models::Model#complete}.
18
+ #
19
+ # @param kase [Datasets::Case] the case whose input is the prompt
20
+ # @param model [Models::Model] the model to call
21
+ # @return [Results::Reply]
17
22
  def call(kase, model:)
18
- completion = model.complete(system: SYSTEM, user: kase.input, json: true)
19
- output = JSON.parse(completion.text.strip.gsub(FENCE, ""))
20
- return Reply.new(error: "reply is not a JSON object", raw: completion.text, usage: completion.usage) unless output.is_a?(Hash)
23
+ provider_response = model.complete(system: SYSTEM, user: kase.input, json: true)
24
+ output = JSON.parse(provider_response.text.strip.gsub(FENCE, ""))
25
+ return Results::Reply.new(error: "reply is not a JSON object", raw: provider_response.text, usage: provider_response.usage) unless output.is_a?(Hash)
21
26
 
22
- Reply.new(output: output, raw: completion.text, usage: completion.usage)
27
+ Results::Reply.new(output: output, raw: provider_response.text, usage: provider_response.usage)
23
28
  rescue JSON::ParserError
24
- Reply.new(error: "reply is not JSON", raw: completion.text, usage: completion.usage)
29
+ Results::Reply.new(error: "reply is not JSON", raw: provider_response.text, usage: provider_response.usage)
25
30
  end
26
31
  end
27
32
  end
@@ -3,12 +3,14 @@
3
3
  module LLM
4
4
  module Evals
5
5
  module Testing
6
- # A {Model} on a {FakeProvider} of its own, for specs that run an
7
- # adapter or an {Experiment} without the CLI.
8
- class FakeModel < Model
9
- # @param id [String]
10
- # @param settings [Hash]
11
- # @param provider_options [Hash] passed to {FakeProvider#initialize}
6
+ # A {Models::Model} on a {FakeProvider} of its own, for specs that run an
7
+ # adapter or an {Actions::Experiment} without the CLI.
8
+ class FakeModel < Models::Model
9
+ # Builds a fresh {FakeProvider}, so its recorded calls belong to this model.
10
+ #
11
+ # @param id [String] the model id
12
+ # @param settings [Hash{Symbol => Object}] effort, temperature, max_tokens
13
+ # @param provider_options [Hash{Symbol => Object}] passed to {FakeProvider#initialize}
12
14
  def initialize(id: "fake/echo", settings: { effort: nil, temperature: 0.2, max_tokens: 4096 }, **provider_options)
13
15
  super(id: id, provider: FakeProvider.new(**provider_options), settings: settings)
14
16
  end