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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Actions
6
+ # How many failed calls a run tolerates, from `errors.abort_after`: at least
7
+ # `total` errors, and, when `percent` is set, at least that share of the
8
+ # calls finished so far. Shared by every experiment's threads, and once
9
+ # spent it stays spent, so a late success cannot restart an aborted run.
10
+ class ErrorBudget
11
+ attr_reader :total, :percent, :errors, :calls
12
+
13
+ # @!attribute [r] total
14
+ # The fewest errors at which the run may abort.
15
+ # @return [Integer]
16
+
17
+ # @!attribute [r] percent
18
+ # The share of finished calls that must also have failed, from 0 to 100.
19
+ # @return [Numeric, nil]
20
+
21
+ # @!attribute [r] errors
22
+ # Failed calls so far, counted across every experiment sharing this budget.
23
+ # @return [Integer]
24
+
25
+ # @!attribute [r] calls
26
+ # Finished calls so far, whether or not they failed.
27
+ # @return [Integer]
28
+
29
+ # @param total [Integer] errors before the run may abort
30
+ # @param percent [Numeric, nil] the share of finished calls that must have failed
31
+ def initialize(total:, percent: nil)
32
+ @total = total
33
+ @percent = percent
34
+ @errors = 0
35
+ @calls = 0
36
+ @spent = false
37
+ @lock = Mutex.new
38
+ end
39
+
40
+ # Counts one finished call, and marks the budget spent once both limits are met.
41
+ #
42
+ # @param result [Results::RunResult] the finished call
43
+ # @return [void]
44
+ def record(result)
45
+ @lock.synchronize do
46
+ @calls += 1
47
+ @errors += 1 if result.errored?
48
+ @spent ||= @errors >= total && (percent.nil? || @errors * 100.0 / @calls >= percent)
49
+ end
50
+ end
51
+
52
+ # @return [Boolean] whether the run must stop
53
+ def spent? = @lock.synchronize { @spent }
54
+
55
+ # @return [String] why the run stopped
56
+ def reason
57
+ limit = percent ? "#{total} errors at #{percent}%" : "#{total} errors"
58
+ "run aborted: #{errors} of #{calls} finished calls failed (errors.abort_after: #{limit}); partial results were kept"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Actions
6
+ # What a streak of failed calls has in common: the messages, and how much of
7
+ # the run they touch. One failing model and a failing key call for different
8
+ # answers, so this names which it is.
9
+ #
10
+ # @!attribute [r] failures
11
+ # @return [Array<Results::RunResult>] the failed calls
12
+ # @!attribute [r] runs
13
+ # @return [Array<Experiment>] the whole run, which "all" is measured against
14
+ ErrorDiagnosis = Data.define(:failures, :runs) do
15
+ # The distinct messages, most frequent first.
16
+ #
17
+ # @return [Array<Array(String, Integer)>] each message with its count
18
+ def messages
19
+ failures.map { it.error || it.reply.error }.map(&:to_s).tally.sort_by { |message, count| [-count, message] }
20
+ end
21
+
22
+ # @return [Scope] the models that failed
23
+ def models = scope(failures.map { it.experiment.model.id }, runs.map { it.model.id })
24
+
25
+ # @return [Scope] the datasets that failed
26
+ def datasets = scope(failures.map { it.case.dataset }, runs.map(&:dataset))
27
+
28
+ # @return [Scope] the cases that failed
29
+ def cases = scope(failures.map { it.case.key }, runs.flat_map(&:cases).map(&:key))
30
+
31
+ private
32
+
33
+ # @param failed [Array<String>] one name per failure
34
+ # @param planned [Array<String>] one name per planned item
35
+ # @return [Scope]
36
+ def scope(failed, planned) = ErrorDiagnosis::Scope.new(failed: failed.uniq.sort, total: planned.uniq.size)
37
+ end
38
+
39
+ # How much of one dimension of the run has failed.
40
+ #
41
+ # @!attribute [r] failed
42
+ # @return [Array<String>] the names that failed, sorted
43
+ # @!attribute [r] total
44
+ # @return [Integer] how many the run planned
45
+ ErrorDiagnosis::Scope = Data.define(:failed, :total) do
46
+ # @return [Boolean] whether every planned one failed
47
+ def all? = failed.size >= total
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Actions
6
+ # Notices when every call has been failing, and holds the run while someone
7
+ # decides what to do. A bad key or a dead endpoint fails everything, and
8
+ # without this the run spends its whole matrix finding that out. Unlike
9
+ # {ErrorBudget}, which the lineup opts into and which only stops, this is
10
+ # always on and can resume. Shared by every experiment's threads.
11
+ class ErrorStreak
12
+ # Failures in a row, with no success between them, that pause the run.
13
+ LIMIT = 100
14
+
15
+ # @param limit [Integer] failures in a row that pause the run
16
+ def initialize(limit: LIMIT)
17
+ @limit = limit
18
+ @failures = []
19
+ @paused = false
20
+ @stopped = false
21
+ @lock = Mutex.new
22
+ @resumed = ConditionVariable.new
23
+ end
24
+
25
+ # Counts one finished call. The call that completes a streak is the one
26
+ # that asks; the other workers wait in {#hold}. Calls still in flight
27
+ # while it asks do not count, so one question cannot lead straight to another.
28
+ #
29
+ # @param result [Results::RunResult] the finished call
30
+ # @yieldparam failures [Array<Results::RunResult>] the streak, oldest first
31
+ # @yieldreturn [Boolean] whether the run should carry on
32
+ # @return [void]
33
+ def record(result)
34
+ failures = @lock.synchronize { tally(result) }
35
+ settle { yield failures } if failures
36
+ end
37
+
38
+ # Blocks while the run is paused. Workers call it before starting a case.
39
+ #
40
+ # @return [void]
41
+ def hold
42
+ @lock.synchronize { @resumed.wait(@lock) while @paused }
43
+ end
44
+
45
+ # @return [Boolean] whether the answer was to stop
46
+ def stopped? = @lock.synchronize { @stopped }
47
+
48
+ # @return [String] why the run stopped
49
+ def reason = "run stopped after #{@limit} failed calls in a row; partial results were kept"
50
+
51
+ private
52
+
53
+ # @param result [Results::RunResult]
54
+ # @return [Array<Results::RunResult>, nil] the streak, once it reaches the limit
55
+ def tally(result)
56
+ return if @paused || @stopped
57
+
58
+ result.errored? ? @failures << result : @failures.clear
59
+ return if @failures.size < @limit
60
+
61
+ @paused = true
62
+ @failures.dup.tap { @failures.clear }
63
+ end
64
+
65
+ # Releases the workers whatever happens: an exception or an interrupt
66
+ # while asking counts as a stop, since nobody said to carry on.
67
+ #
68
+ # @yieldreturn [Boolean] whether the run should carry on
69
+ # @return [void]
70
+ def settle
71
+ carry_on = false
72
+ carry_on = yield
73
+ ensure
74
+ @lock.synchronize do
75
+ @paused = false
76
+ @stopped = !carry_on
77
+ @resumed.broadcast
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Actions
6
+ # One model at one setting over every case of one dataset, run on a
7
+ # thread pool the gem owns, plus the per-case detail the terminal report
8
+ # needs. Trackers only watch: {Trackers::Local} always, and at most one
9
+ # remote tracker, each told when the experiment starts, when a case
10
+ # finishes, and what the summary was.
11
+ class Experiment
12
+ # What one finished experiment reports back. `means` holds one entry
13
+ # per metric plus "score", the mean of each case's overall value;
14
+ # `usage` is summed over the cases; `permalink` is the remote tracker's
15
+ # page, when it has one.
16
+ #
17
+ # @!attribute [r] model
18
+ # @return [Models::Model] what ran
19
+ # @!attribute [r] dataset
20
+ # @return [String] the dataset's name
21
+ # @!attribute [r] name
22
+ # @return [String] the experiment's {Experiment#name}
23
+ # @!attribute [r] means
24
+ # @return [Hash{String => Float, nil}] one mean per metric, plus "score"
25
+ # @!attribute [r] results
26
+ # @return [Array<Results::RunResult>] in the experiment's case order
27
+ # @!attribute [r] errors
28
+ # @return [Array<String>] one "case: message" per call that raised
29
+ # @!attribute [r] duration
30
+ # @return [Float] seconds, less an estimate of the time spent on rate limits
31
+ # @!attribute [r] permalink
32
+ # @return [String, nil] the remote tracker's page
33
+ # @!attribute [r] usage
34
+ # @return [Stats::Usage] summed over the cases
35
+ Summary = Data.define(:model, :dataset, :name, :means, :results, :errors, :duration, :permalink, :usage) do
36
+ # @return [Float, nil] the mean overall score
37
+ def score = means["score"]
38
+
39
+ # @param metric [Scoring::Metric, String]
40
+ # @return [Float, nil]
41
+ def mean(metric) = means[metric.respond_to?(:name) ? metric.name : metric.to_s]
42
+
43
+ # @return [Float, nil] the mean seconds of the calls that worked
44
+ def latency = Stats.mean(worked_durations)
45
+
46
+ # @return [Float, nil] the 95th percentile seconds of the calls that worked
47
+ def latency_p95 = Stats.percentile(worked_durations, Stats::CallStats::P95)
48
+
49
+ # Each working call's own seconds, which {#latency} and {#latency_p95} read.
50
+ #
51
+ # @return [Array<Float>] errored calls are left out
52
+ def worked_durations = results.reject(&:errored?).map(&:duration)
53
+
54
+ # The same experiment over one variant's cases. Its duration is the sum
55
+ # of those cases' own, since they overlapped with the others.
56
+ #
57
+ # @param variant [String]
58
+ # @param metrics [Array<Scoring::Metric>] the scorer's
59
+ # @return [Summary]
60
+ def for_variant(variant, metrics)
61
+ chosen = results.select { it.case.variant == variant }
62
+ with(means: Experiment.means(metrics, chosen),
63
+ results: chosen,
64
+ errors: chosen.select(&:failed?).map { "#{it.case.key}: #{it.error}" },
65
+ duration: chosen.sum(&:duration),
66
+ usage: Experiment.usage(chosen))
67
+ end
68
+ end
69
+
70
+ # Averages what the results scored; a failed case has no score and is skipped.
71
+ #
72
+ # @param metrics [Array<Scoring::Metric>]
73
+ # @param results [Array<Results::RunResult>]
74
+ # @return [Hash{String => Float, nil}] one mean per metric, plus "score"
75
+ def self.means(metrics, results)
76
+ scored = results.filter_map(&:score)
77
+ means = metrics.to_h { |metric| [metric.name, metric.combine(scored.map { it.metrics[metric.name] })] }
78
+ means.merge("score" => scored.empty? ? nil : scored.sum(&:value).fdiv(scored.size))
79
+ end
80
+
81
+ # Tokens and cost across the results; a raised case has no reply and adds nothing.
82
+ #
83
+ # @param results [Array<Results::RunResult>]
84
+ # @return [Stats::Usage] summed over the results
85
+ def self.usage(results) = results.filter_map(&:reply).map(&:usage).reduce(Stats::Usage.zero, :+)
86
+
87
+ attr_reader :model, :adapter, :scorer, :cases, :dataset, :parallelism, :trackers
88
+
89
+ # @!attribute [r] model
90
+ # The model at the setting under test.
91
+ # @return [Models::Model]
92
+
93
+ # @!attribute [r] adapter
94
+ # Runs each case.
95
+ # @return [Adapter]
96
+
97
+ # @!attribute [r] scorer
98
+ # Scores each reply.
99
+ # @return [Scoring::Scorer]
100
+
101
+ # @!attribute [r] cases
102
+ # Sorted by variant, number and id.
103
+ # @return [Array<Datasets::Case>]
104
+
105
+ # @!attribute [r] dataset
106
+ # The name of the dataset the first case came from.
107
+ # @return [String]
108
+
109
+ # @!attribute [r] parallelism
110
+ # Cases in flight at once, never below 1.
111
+ # @return [Integer]
112
+
113
+ # @!attribute [r] trackers
114
+ # Only the enabled ones of those given, in the order given.
115
+ # @return [Array<Trackers::Base>]
116
+
117
+ # @param model [Models::Model]
118
+ # @param cases [Array<Datasets::Case>] all of one dataset
119
+ # @param adapter [Adapter]
120
+ # @param scorer [Scoring::Scorer]
121
+ # @param parallelism [Integer] cases in flight at once
122
+ # @param trackers [Array<Trackers::Base>] told about the run, in this order
123
+ # @raise [ArgumentError] when there are no cases
124
+ def initialize(model:, cases:, adapter:, scorer:, parallelism: 4, trackers: [])
125
+ raise ArgumentError, "an experiment needs at least one case" if cases.empty?
126
+
127
+ @model = model
128
+ @cases = cases.sort_by { [it.variant, it.number || 0, it.id] }
129
+ @dataset = cases.first.dataset
130
+ @adapter = adapter
131
+ @scorer = scorer
132
+ @parallelism = [parallelism.to_i, 1].max
133
+ @trackers = trackers.select(&:enabled?)
134
+ @results = {}
135
+ @lock = Mutex.new
136
+ @waited = 0.0
137
+ end
138
+
139
+ # How reports and trackers tell this experiment from the others of a run.
140
+ #
141
+ # @return [String] e.g. "anthropic/claude-haiku-5.5 effort=low t=0.0 [clear]"
142
+ def name = "#{model} [#{dataset}]"
143
+
144
+ # Runs every case, then asks the trackers for a page to link the summary to.
145
+ #
146
+ # @param on_case [#call, nil] called with each finished case's {Results::RunResult}
147
+ # @param stop [#call, nil] asked before each case; truthy leaves the rest unrun
148
+ # @return [Summary]
149
+ def run(on_case: nil, stop: nil)
150
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
151
+ adapter.prepare(dataset)
152
+ trackers.each { it.start(self) }
153
+ errors = run_locally(on_case, stop)
154
+ # An approximation: it assumes waits fell evenly across the workers. Workers
155
+ # that wait while others run did not hold up the run for that long.
156
+ summary = summarize(errors, nil, Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - (@waited / workers))
157
+ urls = trackers.map { it.finish(self, summary)&.fetch(:url, nil) }
158
+ summary.with(permalink: urls.compact.first)
159
+ end
160
+
161
+ # Runs one case: the adapter, then the scorer. A reply the model failed
162
+ # (`Reply#error`) scores 0 without troubling the scorer; an exception is
163
+ # the transport's and is left to the caller, which counts it as an error.
164
+ #
165
+ # @param kase [Datasets::Case]
166
+ # @return [Results::RunResult]
167
+ # @raise [Error] when the adapter returns anything but a {Results::Reply}
168
+ def evaluate(kase)
169
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
170
+ reply, waited = Models::Throttle.measure { adapter.call(kase, model: model) }
171
+ @lock.synchronize { @waited += waited }
172
+ raise Error, "#{adapter.class}#call returned #{reply.class}, not an LLM::Evals::Results::Reply" unless reply.is_a?(Results::Reply)
173
+
174
+ score = reply.error? ? Results::Score.failed(scorer.metrics, reply.error) : scorer.call(kase, reply, context: adapter.context(kase))
175
+ Results::RunResult.new(experiment: self,
176
+ case: kase,
177
+ reply: reply,
178
+ score: score,
179
+ duration: Process.clock_gettime(Process::CLOCK_MONOTONIC) - started - waited)
180
+ end
181
+
182
+ private
183
+
184
+ # Threads worth starting: more than there are cases would only sit idle.
185
+ #
186
+ # @return [Integer]
187
+ def workers = [parallelism, cases.size].min
188
+
189
+ # Remembers a result and tells the trackers, under one lock, so a
190
+ # tracker sees results one at a time and in the order they are kept.
191
+ #
192
+ # @param result [Results::RunResult] the finished case
193
+ # @return [Results::RunResult] the same result
194
+ def remember(result)
195
+ @lock.synchronize do
196
+ @results[result.case.key] = result
197
+ trackers.each { it.record(result) }
198
+ end
199
+ result
200
+ end
201
+
202
+ # A fixed pool of threads over a queue: `parallelism` cases in flight.
203
+ #
204
+ # @param on_case [#call, nil] called with each finished case's {Results::RunResult}
205
+ # @param stop [#call, nil] asked before each case; truthy leaves the rest unrun
206
+ # @return [Array<String>] one "case: message" per case that raised
207
+ def run_locally(on_case, stop)
208
+ queue = Queue.new
209
+ cases.each { queue << it }
210
+ queue.close
211
+ errors = Queue.new
212
+ Array.new(workers) do
213
+ Thread.new do
214
+ while !stop&.call && (kase = queue.pop)
215
+ result = begin
216
+ remember evaluate(kase)
217
+ rescue StandardError => e
218
+ errors << "#{kase.key}: #{e.class}: #{e.message}"
219
+ remember Results::RunResult.new(experiment: self, case: kase, error: "#{e.class}: #{e.message}")
220
+ end
221
+ on_case&.call(result)
222
+ end
223
+ end
224
+ end.each(&:join)
225
+ Array.new(errors.size) { errors.pop }
226
+ end
227
+
228
+ # Freezes what has been remembered so far into a {Summary}, in case order.
229
+ #
230
+ # @param errors [Array<String>, nil] the messages {#run_locally} collected
231
+ # @param permalink [String, nil] the remote tracker's page, when already known
232
+ # @param duration [Float] seconds to report
233
+ # @return [Summary]
234
+ def summarize(errors, permalink, duration)
235
+ results = @lock.synchronize { @results.values }.sort_by { cases.index(it.case) }
236
+ Summary.new(
237
+ model: model,
238
+ dataset: dataset,
239
+ name: name,
240
+ means: self.class.means(scorer.metrics, results),
241
+ results: results,
242
+ errors: Array(errors),
243
+ duration: duration,
244
+ permalink: permalink,
245
+ usage: self.class.usage(results)
246
+ )
247
+ end
248
+ end
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Actions
6
+ # Offline consistency checks over every case, so a typo in the suite is
7
+ # caught before it is billed as a model's mistake. The gem checks what
8
+ # every suite has: an input and a parsable expected answer. The adapter
9
+ # checks each dataset's shared files, and the scorer each case against
10
+ # what it will demand of it.
11
+ class Verify
12
+ # @param configuration [Runtime::Configuration] the suite to check
13
+ def initialize(configuration = LLM::Evals.configuration)
14
+ @configuration = configuration
15
+ end
16
+
17
+ # Asks the adapter about each dataset, then checks every case in it.
18
+ #
19
+ # @return [Hash{String => Array<String>}] problems by "dataset/id"; empty when all is sound
20
+ def problems
21
+ discovery = @configuration.discovery
22
+ failures = {}
23
+ discovery.datasets.each do |dataset|
24
+ Array(adapter.verify(dataset)).each { (failures[dataset.to_s] ||= []) << it }
25
+ discovery.cases(dataset: dataset).each do |kase|
26
+ list = case_problems(kase)
27
+ failures[kase.key] = list unless list.empty?
28
+ end
29
+ end
30
+ failures
31
+ end
32
+
33
+ # Checks one case; an exception from the scorer or adapter is reported as a problem.
34
+ #
35
+ # @param kase [Datasets::Case] the case to check
36
+ # @return [Array<String>] problems; empty when the case is sound
37
+ def case_problems(kase)
38
+ problems = []
39
+ problems << "input is empty" if kase.input.empty?
40
+ problems << kase.metadata[:expected_error] if kase.metadata[:expected_error]
41
+ return problems unless kase.expected?
42
+
43
+ problems.concat(Array(scorer.verify(kase, context: adapter.context(kase))))
44
+ rescue StandardError => e
45
+ problems << "#{e.class}: #{e.message}"
46
+ end
47
+
48
+ private
49
+
50
+ # The suite's adapter.
51
+ #
52
+ # @return [Actions::Adapter, nil] nil when the suite has not registered one
53
+ def adapter = @configuration.adapter
54
+ # The suite's scorer.
55
+ #
56
+ # @return [Scoring::Scorer, nil] nil when the suite has not registered one
57
+ def scorer = @configuration.scorer
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # What the gem does with a suite: runs experiments through its adapter, and verifies it.
6
+ module Actions
7
+ end
8
+ end
9
+ end
@@ -9,6 +9,11 @@ module LLM
9
9
 
10
10
  option :dataset, type: :array, aliases: ["-d"], default: [], desc: "Only these datasets, comma-separated"
11
11
 
12
+ # Prints one row per case, then whatever the lineup cannot ask.
13
+ #
14
+ # @param dataset [Array<String>] as given to -d, possibly comma-separated; empty means every dataset
15
+ # @return [void]
16
+ # @raise [Error] when no case is found
12
17
  def call(dataset: [], **)
13
18
  scorer = configuration.scorer
14
19
  names = dataset.flat_map { it.split(",") }
@@ -26,6 +31,8 @@ module LLM
26
31
 
27
32
  # Names, on STDERR, every lineup entry the suite cannot ask, so the
28
33
  # mismatch is visible before anyone reaches for `run`.
34
+ #
35
+ # @return [void]
29
36
  def refusals
30
37
  config = configuration.lineup
31
38
  _, refused = configuration.providers.admit(config.contenders, request_types: config.request_types)
@@ -34,6 +41,9 @@ module LLM
34
41
 
35
42
  # The generic cell when the scorer has nothing to say: how many fields
36
43
  # the expected answer holds, or what kind of thing it is.
44
+ #
45
+ # @param kase [Datasets::Case]
46
+ # @return [String]
37
47
  def summary(kase)
38
48
  case kase.expected
39
49
  when Hash then "#{kase.expected.size} fields"