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,158 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # One model at one setting over every case of one dataset, run on a
6
- # thread pool the gem owns, plus the per-case detail the terminal report
7
- # needs. Trackers only watch: {Trackers::Local} always, and at most one
8
- # remote tracker, each told when the experiment starts, when a case
9
- # finishes, and what the summary was.
10
- class Experiment
11
- # What one finished experiment reports back. `means` holds one entry
12
- # per metric plus "score", the mean of each case's overall value;
13
- # `usage` is summed over the cases; `permalink` is the remote tracker's
14
- # page, when it has one.
15
- Summary = Data.define(:model, :dataset, :name, :means, :results, :errors, :duration, :permalink, :usage) do
16
- # @return [Float, nil] the mean overall score
17
- def score = means["score"]
18
-
19
- # @param metric [Metric, String]
20
- # @return [Float, nil]
21
- def mean(metric) = means[metric.respond_to?(:name) ? metric.name : metric.to_s]
22
-
23
- # The same experiment over one variant's cases. Its duration is the sum
24
- # of those cases' own, since they overlapped with the others.
25
- #
26
- # @param variant [String]
27
- # @param metrics [Array<Metric>] the scorer's
28
- # @return [Summary]
29
- def for_variant(variant, metrics)
30
- chosen = results.select { it.case.variant == variant }
31
- with(means: Experiment.means(metrics, chosen),
32
- results: chosen,
33
- errors: chosen.select(&:failed?).map { "#{it.case.key}: #{it.error}" },
34
- duration: chosen.sum(&:duration),
35
- usage: Experiment.usage(chosen))
36
- end
37
- end
38
-
39
- # @param metrics [Array<Metric>]
40
- # @param results [Array<RunResult>]
41
- # @return [Hash{String => Float, nil}] one mean per metric, plus "score"
42
- def self.means(metrics, results)
43
- scored = results.filter_map(&:score)
44
- means = metrics.to_h { |metric| [metric.name, metric.combine(scored.map { it.metrics[metric.name] })] }
45
- means.merge("score" => scored.empty? ? nil : scored.sum(&:value).fdiv(scored.size))
46
- end
47
-
48
- # @param results [Array<RunResult>]
49
- # @return [Usage] summed over the results
50
- def self.usage(results) = results.filter_map(&:reply).map(&:usage).reduce(Usage.zero, :+)
51
-
52
- attr_reader :model, :adapter, :scorer, :cases, :dataset, :parallelism, :trackers
53
-
54
- # @param model [Model]
55
- # @param cases [Array<Case>] all of one dataset
56
- # @param adapter [Adapter]
57
- # @param scorer [Scorer]
58
- # @param parallelism [Integer] cases in flight at once
59
- # @param trackers [Array<Tracker>] told about the run, in this order
60
- def initialize(model:, cases:, adapter:, scorer:, parallelism: 4, trackers: [])
61
- raise ArgumentError, "an experiment needs at least one case" if cases.empty?
62
-
63
- @model = model
64
- @cases = cases.sort_by { [it.variant, it.number || 0, it.id] }
65
- @dataset = cases.first.dataset
66
- @adapter = adapter
67
- @scorer = scorer
68
- @parallelism = [parallelism.to_i, 1].max
69
- @trackers = trackers.select(&:enabled?)
70
- @results = {}
71
- @lock = Mutex.new
72
- end
73
-
74
- # @return [String] e.g. "anthropic/claude-haiku-5.5 effort=low t=0.0 [clear]"
75
- def name = "#{model} [#{dataset}]"
76
-
77
- # @param on_case [#call, nil] called once per finished case
78
- # @return [Summary]
79
- def run(on_case: nil)
80
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
- adapter.prepare(dataset)
82
- trackers.each { it.start(self) }
83
- errors = run_locally(on_case)
84
- summary = summarize(errors, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
85
- urls = trackers.map { it.finish(self, summary)&.fetch(:url, nil) }
86
- summary.with(permalink: urls.compact.first)
87
- end
88
-
89
- # Runs one case: the adapter, then the scorer. A reply the model failed
90
- # (`Reply#error`) scores 0 without troubling the scorer; an exception is
91
- # the transport's and is left to the caller, which counts it as an error.
92
- #
93
- # @param kase [Case]
94
- # @return [RunResult]
95
- def evaluate(kase)
96
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
- reply = adapter.call(kase, model: model)
98
- raise Error, "#{adapter.class}#call returned #{reply.class}, not an LLM::Evals::Reply" unless reply.is_a?(Reply)
99
-
100
- score = reply.error? ? Score.failed(scorer.metrics, reply.error) : scorer.call(kase, reply, context: adapter.context(kase))
101
- RunResult.new(experiment: self,
102
- case: kase,
103
- reply: reply,
104
- score: score,
105
- duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
106
- end
107
-
108
- private
109
-
110
- # Remembers a result and tells the trackers, under one lock, so a
111
- # tracker sees results one at a time and in the order they are kept.
112
- def remember(result)
113
- @lock.synchronize do
114
- @results[result.case.key] = result
115
- trackers.each { it.record(result) }
116
- end
117
- result
118
- end
119
-
120
- # A fixed pool of threads over a queue: `parallelism` cases in flight.
121
- def run_locally(on_case)
122
- queue = Queue.new
123
- cases.each { queue << it }
124
- queue.close
125
- errors = Queue.new
126
- Array.new([parallelism, cases.size].min) do
127
- Thread.new do
128
- while (kase = queue.pop)
129
- begin
130
- remember evaluate(kase)
131
- rescue StandardError => e
132
- remember RunResult.new(experiment: self, case: kase, error: "#{e.class}: #{e.message}")
133
- errors << "#{kase.key}: #{e.class}: #{e.message}"
134
- end
135
- on_case&.call
136
- end
137
- end
138
- end.each(&:join)
139
- Array.new(errors.size) { errors.pop }
140
- end
141
-
142
- def summarize(errors, permalink, duration)
143
- results = @lock.synchronize { @results.values }.sort_by { cases.index(it.case) }
144
- Summary.new(
145
- model: model,
146
- dataset: dataset,
147
- name: name,
148
- means: self.class.means(scorer.metrics, results),
149
- results: results,
150
- errors: Array(errors),
151
- duration: duration,
152
- permalink: permalink,
153
- usage: self.class.usage(results)
154
- )
155
- end
156
- end
157
- end
158
- end
@@ -1,98 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # Finds and loads the suite: `evals/eval_helper.rb`, the file that calls
6
- # {LLM::Evals.configure}, or, for a config-only suite, the directory
7
- # holding `evals/.llm-evals.config.yml`. The executable looks before
8
- # dispatching, the way rspec finds spec_helper, so the suite's own
9
- # directory needs no wrapper script.
10
- module Helper
11
- FILE = File.join("evals", "eval_helper.rb")
12
- ENV_VAR = "LLM_EVALS_HELPER"
13
- FLAG = "--helper"
14
-
15
- # What {.locate} found: a helper to load, a config file to read, or neither.
16
- Found = Data.define(:helper, :config) do
17
- def initialize(helper: nil, config: nil) = super
18
-
19
- # @return [Boolean] whether there is anything to load
20
- def any? = !helper.nil? || !config.nil?
21
-
22
- # @return [Boolean]
23
- def none? = !any?
24
- end
25
-
26
- module_function
27
-
28
- # Pulls `--helper PATH` (or `--helper=PATH`) out of argv, so dry-cli
29
- # never sees a flag no command declares.
30
- #
31
- # @param argv [Array<String>]
32
- # @return [Array(Array<String>, String, nil)] the remaining arguments and the path, if given
33
- def extract(argv)
34
- rest = []
35
- path = nil
36
- skip = false
37
- argv.each_with_index do |arg, index|
38
- next skip = false if skip
39
-
40
- if arg == FLAG
41
- path = argv[index + 1]
42
- skip = true
43
- elsif arg.start_with?("#{FLAG}=")
44
- path = arg.delete_prefix("#{FLAG}=")
45
- else
46
- rest << arg
47
- end
48
- end
49
- [rest, path]
50
- end
51
-
52
- # The suite to load: the flag, then the variable, then, from the
53
- # directory upward, the nearest `evals/eval_helper.rb` or, failing
54
- # that, the nearest `evals/.llm-evals.config.yml` (also found from
55
- # inside `evals/` itself).
56
- #
57
- # @param explicit [String, nil] what `--helper` was given
58
- # @param from [String] the directory to search up from
59
- # @param env [Hash] the environment
60
- # @return [Found] absolute paths; empty when there is none
61
- def locate(explicit = nil, from: Dir.pwd, env: ENV)
62
- candidate = explicit || env[ENV_VAR]
63
- return Found.new(helper: File.expand_path(candidate, from)) if candidate && !candidate.empty?
64
-
65
- Pathname(from).expand_path.ascend do |dir|
66
- helper = dir.join(FILE)
67
- config = [dir.join("evals", Configuration::DEFAULT_CONFIG), dir.join(Configuration::DEFAULT_CONFIG)].find(&:file?)
68
- return Found.new(helper: helper.to_s, config: config&.to_s) if helper.file?
69
- return Found.new(config: config.to_s) if config
70
- end
71
- Found.new
72
- end
73
-
74
- # Loads the helper, once, or roots the configuration at a config-only
75
- # suite's directory so {Configuration#resolve_from_config!} can read it.
76
- # A helper is loaded first and wins; the config fills what it left unset.
77
- #
78
- # @param found [Found, String, nil] nothing is loaded for nil or an empty Found
79
- # @param configuration [Configuration]
80
- # @return [Boolean] whether anything was loaded
81
- # @raise [Error] when the helper does not exist
82
- def load!(found, configuration: LLM::Evals.configuration)
83
- found = Found.new(helper: found) if found.is_a?(String)
84
- return false if found.nil? || found.none?
85
-
86
- if found.helper
87
- raise Error, "no eval helper at #{found.helper}" unless File.file?(found.helper)
88
-
89
- require File.expand_path(found.helper)
90
- else
91
- root = File.dirname(found.config)
92
- configuration.suite(File.basename(File.dirname(root)), root: root) unless configuration.complete?
93
- end
94
- true
95
- end
96
- end
97
- end
98
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # One number a {Scorer} reports per case. `aggregate` is how the run
6
- # combines them (only :mean today), skipping nil, so a metric a case does
7
- # not apply to never drags its mean down with a free 0 or up with a free 1.
8
- # The `primary` metric ranks the leaderboard and picks the best value.
9
- class Metric < Data.define(:name, :description, :aggregate, :primary)
10
- AGGREGATES = %i[mean].freeze
11
-
12
- def initialize(name:, description: "", aggregate: :mean, primary: false)
13
- raise ArgumentError, "aggregate must be one of #{AGGREGATES.join(", ")}" unless AGGREGATES.include?(aggregate)
14
-
15
- super(name: name.to_s, description: description, aggregate: aggregate, primary: primary)
16
- end
17
-
18
- # @param values [Array<Float, nil>] one per case
19
- # @return [Float, nil] nil when no case reported the metric
20
- def combine(values)
21
- present = values.compact
22
- present.sum.fdiv(present.size) unless present.empty?
23
- end
24
- end
25
- end
26
- end
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # One model at one setting, reached through its provider. Adapters call
6
- # {#complete}; the settings an experiment is named by are read here.
7
- class Model
8
- # The settings every provider understands; a provider may accept more.
9
- SETTINGS = %i[effort temperature max_tokens].freeze
10
-
11
- attr_reader :id, :provider, :settings
12
-
13
- # @param id [String] the model id as the lineup names it, e.g. "anthropic/claude-haiku-5.5"
14
- # @param provider [Providers::Base] what answers {#complete}
15
- # @param settings [Hash{Symbol => Object}] effort, temperature, max_tokens; nil leaves the model's default
16
- def initialize(id:, provider:, settings: {})
17
- @id = id
18
- @provider = provider
19
- @settings = settings.transform_keys(&:to_sym).freeze
20
- end
21
-
22
- # @return [String, nil] reasoning effort; nil sends none
23
- def effort = settings[:effort]
24
-
25
- # @return [Float, nil] sampling temperature; nil sends none
26
- def temperature = settings[:temperature]
27
-
28
- # @return [Integer, nil] output token cap
29
- def max_tokens = settings[:max_tokens]
30
-
31
- # @return [String] the id without the provider's prefix, as the provider's API knows it
32
- def name = provider.model_name(id)
33
-
34
- # @return [String] "provider:id", unique where two providers render the same id
35
- def key = "#{provider.name}:#{id}"
36
-
37
- # One call. Transport failures raise; they are the run's errors, not the model's.
38
- #
39
- # @param system [String, nil]
40
- # @param user [String]
41
- # @param json [Boolean] ask the provider for a JSON object reply where it supports that
42
- # @return [Completion]
43
- def complete(user:, system: nil, json: true)
44
- provider.complete(self, system: system, user: user, json: json)
45
- end
46
-
47
- # @return [String] e.g. "effort=low t=0.2"
48
- def label
49
- "effort=#{effort || "default"} t=#{temperature.nil? ? "default" : temperature}"
50
- end
51
-
52
- def to_s = "#{id} #{label}"
53
- end
54
- end
55
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # What an {Adapter} returns for one case. `error` is the model's failure
6
- # (it did not answer the question: no JSON, a refusal) and scores 0; a
7
- # transport failure is not the model's and is raised instead, so it is
8
- # counted as an error of the run rather than a wrong answer.
9
- Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
10
- def initialize(output: nil, raw: nil, usage: Usage.zero, error: nil, metadata: {})
11
- super(output: output, raw: raw, usage: Usage.from(usage), error: error, metadata: metadata)
12
- end
13
-
14
- # @return [Boolean] whether the model failed to answer
15
- def error? = !error.nil?
16
- end
17
- end
18
- end
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # One case, run once: what came back and what it scored. `reply` is nil
6
- # when the adapter raised, and `error` then holds the message.
7
- RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
8
- def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
9
-
10
- # @return [Boolean] whether the adapter raised rather than answered
11
- def failed? = !error.nil?
12
- end
13
- end
14
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # Checks a Hash of answers against a field map, for scorers and verifiers
6
- # whose expected values are typed and closed. Nothing here is specific to
7
- # what the fields mean.
8
- #
9
- # The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
10
- # {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
11
- # nullable field accepts nil (the text never answered it).
12
- class SchemaValidator
13
- TYPES = %i[boolean integer number string enum multi_enum].freeze
14
-
15
- attr_reader :fields
16
-
17
- # @param fields [Hash{String, Symbol => Hash}]
18
- # @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
19
- def initialize(fields)
20
- @fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
21
- end
22
-
23
- # @return [Array<String>] the field names
24
- def names = fields.keys
25
-
26
- # @param values [Hash] String or Symbol keys
27
- # @return [Array<String>] every problem found; empty when the values fit the map
28
- def problems(values)
29
- return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
30
-
31
- actual = values.transform_keys(&:to_s)
32
- missing = names - actual.keys
33
- extra = actual.keys - names
34
- problems = []
35
- problems << "missing #{missing.join(", ")}" if missing.any?
36
- problems << "unknown fields #{extra.join(", ")}" if extra.any?
37
- fields.each do |name, spec|
38
- next unless actual.key?(name)
39
-
40
- problem = value_problem(spec, actual[name])
41
- problems << "#{name}: #{problem}" if problem
42
- end
43
- problems
44
- end
45
-
46
- # @param values [Hash]
47
- # @return [Boolean]
48
- def valid?(values) = problems(values).empty?
49
-
50
- # @param spec [Hash] one field's normalized spec
51
- # @param value [Object]
52
- # @return [String, nil] what is wrong with the value, or nil
53
- def value_problem(spec, value)
54
- return (spec[:nullable] ? nil : "is null") if value.nil?
55
-
56
- case spec[:type]
57
- when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
58
- when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
59
- when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
60
- when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
61
- when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
62
- when :multi_enum then multi_enum_problem(spec, value)
63
- end
64
- end
65
-
66
- private
67
-
68
- def multi_enum_problem(spec, value)
69
- return "#{value.inspect} is not an array" unless value.is_a?(Array)
70
-
71
- stray = value - spec[:enum]
72
- "#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
73
- end
74
-
75
- def normalize(name, spec)
76
- spec = spec.transform_keys(&:to_sym)
77
- type = spec.fetch(:type).to_sym
78
- raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
79
-
80
- { type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
81
- end
82
- end
83
- end
84
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # What a {Scorer} says about one reply. `value` is the case's overall
6
- # score in 0..1; `metrics` holds one entry per {Metric} the scorer
7
- # declares, nil where the case does not apply; `outcomes` and
8
- # `diagnostics` are free-form, keyed by field, and only ever displayed.
9
- Score = Data.define(:value, :metrics, :outcomes, :diagnostics) do
10
- def initialize(value:, metrics: {}, outcomes: {}, diagnostics: {})
11
- raise ArgumentError, "score value #{value.inspect} is not within 0..1" unless value.is_a?(Numeric) && (0..1).cover?(value)
12
-
13
- super(value: value.to_f, metrics: metrics.transform_keys(&:to_s), outcomes: outcomes, diagnostics: diagnostics)
14
- end
15
-
16
- # The score of a reply the model failed: 0 everywhere, the failure as a diagnostic.
17
- #
18
- # @param metrics [Array<Metric>]
19
- # @param error [String]
20
- # @return [Score]
21
- def self.failed(metrics, error)
22
- new(value: 0.0, metrics: metrics.to_h { [it.name, 0.0] }, diagnostics: { "error" => error })
23
- end
24
- end
25
- end
26
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # What a suite implements to score one reply. Pure and deterministic: it
6
- # reads the case, the reply and the adapter's context, and calls nothing.
7
- #
8
- # @abstract Subclass and override {#metrics} and {#call}.
9
- class Scorer
10
- # The metrics every {Score} this scorer returns carries. The CLI builds
11
- # its columns, its glossary and its Braintrust scorers from this list,
12
- # so a metric missing here is a metric nobody sees.
13
- #
14
- # @return [Array<Metric>]
15
- def metrics
16
- raise NotImplementedError, "#{self.class}#metrics must return an Array of LLM::Evals::Metric"
17
- end
18
-
19
- # @return [Metric] the one flagged primary, else the first
20
- def primary = metrics.find(&:primary) || metrics.first
21
-
22
- # @param kase [Case]
23
- # @param reply [Reply] never one with `error` set; the runner scores those 0 itself
24
- # @param context [Hash] what {Adapter#context} returned for the case
25
- # @return [Score]
26
- def call(kase, reply, context:)
27
- raise NotImplementedError, "#{self.class}#call(kase, reply, context:) must return an LLM::Evals::Score"
28
- end
29
-
30
- # Offline checks of one case against what the scorer will demand of it,
31
- # so a typo in expected.json is caught before it is billed as a model's mistake.
32
- #
33
- # @param kase [Case]
34
- # @param context [Hash] what {Adapter#context} returned for the case
35
- # @return [Array<String>] problems; empty when the case is sound
36
- def verify(kase, context:) = [] # rubocop:disable Lint/UnusedMethodArgument -- the signature is the contract
37
-
38
- # One short cell describing the case in `llm-evals cases`, e.g. "5/8 answered".
39
- #
40
- # @param kase [Case]
41
- # @return [String, nil] nil for the generic fallback
42
- def summary(_kase) = nil
43
- end
44
- end
45
- end
@@ -1,80 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # Where a run's results go besides the terminal. The gem always owns the
6
- # run loop; a tracker only receives callbacks: {#start} once per
7
- # experiment, {#record} once per finished case, {#finish} once with the
8
- # summary. The local JSON log ({Trackers::Local}) always runs; at most
9
- # one remote tracker runs beside it, chosen by `--tracker`, the
10
- # `tracker:` key, or the environment (see {Trackers::Registry#choose}).
11
- #
12
- # A tracker for another framework (W&B Weave, an OTLP backend) subclasses
13
- # this in a consumer or a plugin gem and is registered with `c.tracker`.
14
- #
15
- # @abstract Override the three callbacks; declare {.env_key} and {.gems}.
16
- class Tracker
17
- class << self
18
- # @return [String] the name `--tracker` and `tracker:` use; the class name, dasherized, by default
19
- def tracker_name = (name || "Tracker").split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
20
-
21
- # @return [String, nil] the environment variable whose presence selects this tracker when nothing else does
22
- def env_key = nil
23
-
24
- # @return [Array<String>] the optional gems this tracker requires when chosen
25
- def gems = []
26
-
27
- # @param env [Hash] the environment
28
- # @return [Boolean] whether the environment names this tracker
29
- def detect?(env = ENV) = !env_key.nil? && !env[env_key].to_s.strip.empty?
30
-
31
- # Loads the gems and checks the key, with an error that names both, so
32
- # a chosen tracker that cannot run says so before any model is called.
33
- #
34
- # @param env [Hash]
35
- # @return [void]
36
- # @raise [MissingDependency, Error]
37
- def requirements!(env = ENV)
38
- gems.each do |gem_name|
39
- LLM::Evals.optional!(gem_name.tr("-", "/"), gem_name, needed_for: "the #{tracker_name} tracker")
40
- end
41
- return if env_key.nil? || detect?(env)
42
-
43
- raise Error, "the #{tracker_name} tracker needs #{env_key} in the environment (gems: #{gems.join(", ")})"
44
- end
45
- end
46
-
47
- attr_reader :context
48
-
49
- # @param context [Hash] `suite:`, `project:`, `project_id:` and `options:` (the tracker's own key in the config file)
50
- def initialize(**context)
51
- @context = context
52
- end
53
-
54
- # @return [String]
55
- def name = self.class.tracker_name
56
-
57
- # @return [Boolean] false turns every callback into a no-op
58
- def enabled? = true
59
-
60
- # @return [String] one line for `--verbose`: where this tracker sends things, and why
61
- def describe = name
62
-
63
- # @param experiment [Experiment]
64
- # @return [void]
65
- def start(_experiment) = nil
66
-
67
- # @param result [RunResult]
68
- # @return [void]
69
- def record(_result) = nil
70
-
71
- # @param experiment [Experiment]
72
- # @param summary [Experiment::Summary]
73
- # @return [Hash] `url:` to the experiment when one is known, else nil
74
- def finish(_experiment, _summary) = { url: nil }
75
-
76
- # @return [Hash] the tracker's own section of the config file
77
- def options = context.fetch(:options, nil) || {}
78
- end
79
- end
80
- end
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module LLM
4
- module Evals
5
- # What one call cost, as the provider counted it. Summed over a run.
6
- Usage = Data.define(:input_tokens, :output_tokens, :cost_usd) do
7
- # @return [Usage] zero of everything
8
- def self.zero = new(input_tokens: 0, output_tokens: 0, cost_usd: 0.0)
9
-
10
- # Reads a Hash with Symbol or String keys, missing keys as zero.
11
- #
12
- # @param hash [Hash, Usage, nil]
13
- # @return [Usage]
14
- def self.from(hash)
15
- return hash if hash.is_a?(Usage)
16
- return zero if hash.nil?
17
-
18
- h = hash.transform_keys(&:to_sym)
19
- new(input_tokens: h.fetch(:input_tokens, 0).to_i,
20
- output_tokens: h.fetch(:output_tokens, 0).to_i,
21
- cost_usd: h.fetch(:cost_usd, 0.0).to_f)
22
- end
23
-
24
- # @param other [Usage]
25
- # @return [Usage]
26
- def +(other)
27
- with(input_tokens: input_tokens + other.input_tokens,
28
- output_tokens: output_tokens + other.output_tokens,
29
- cost_usd: cost_usd + other.cost_usd)
30
- end
31
- end
32
- end
33
- end