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,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "time"
5
4
 
6
5
  module LLM
7
6
  module Evals
@@ -10,161 +9,59 @@ module LLM
10
9
  # A portable snapshot of completed runs, suitable for rendering later
11
10
  # without repeating model calls or depending on the original case files.
12
11
  class JSON
12
+ # Identifies a document as one of these snapshots, so other JSON is refused.
13
13
  FORMAT = "llm-evals-report"
14
+ # The document's schema version; {Decoder} refuses any other.
14
15
  VERSION = 1
16
+ # A snapshot read back: what {HTML} and the terminal display are given.
17
+ #
18
+ # @!attribute [r] suite
19
+ # @return [String] the suite's name
20
+ # @!attribute [r] generated_at
21
+ # @return [String, nil] when the snapshot was written, ISO 8601
22
+ # @!attribute [r] run_options
23
+ # @return [Hash{String => Object}] as the run recorded them, with string keys now
24
+ # @!attribute [r] summaries
25
+ # @return [Array<Actions::Experiment::Summary>]
26
+ # @!attribute [r] metrics
27
+ # @return [Array<Scoring::Metric>]
28
+ # @!attribute [r] weighted_score
29
+ # @return [Scoring::WeightedScore, nil] nil when the lineup set no weights
15
30
  Loaded = Data.define(:suite, :generated_at, :run_options, :summaries, :metrics, :weighted_score)
31
+ # Stands in for the {Actions::Experiment} on a result read back; a snapshot keeps only its model.
32
+ #
33
+ # @!attribute [r] model
34
+ # @return [Models::Model]
16
35
  SavedExperiment = Data.define(:model)
17
36
 
18
- # @param summaries [Array<Experiment::Summary>]
19
- # @param metrics [Array<Metric>]
20
- # @param weighted_score [WeightedScore, nil]
37
+ # Serializes completed runs under the configured suite's name.
38
+ #
39
+ # @param summaries [Array<Actions::Experiment::Summary>]
40
+ # @param metrics [Array<Scoring::Metric>]
41
+ # @param weighted_score [Scoring::WeightedScore, nil]
42
+ # @param run_options [Hash]
21
43
  # @return [String]
22
44
  def self.dump(summaries, metrics:, weighted_score: nil, run_options: {})
23
- document = {
24
- format: FORMAT,
25
- version: VERSION,
26
- suite: LLM::Evals.configuration.name,
27
- generated_at: Time.now.iso8601,
28
- run_options: json_value(run_options),
29
- metrics: metrics.map { { name: it.name, description: it.description, aggregate: it.aggregate, primary: it.primary } },
30
- weights: weighted_score&.weights,
31
- summaries: summaries.map { summary(it) }
32
- }
33
- ::JSON.pretty_generate(document)
45
+ Encoder.new(summaries, metrics:, weighted_score:, run_options:, suite: LLM::Evals.configuration.name).call
34
46
  end
35
47
 
36
- def self.json_value(value)
37
- case value
38
- when Hash then value.to_h { |key, item| [key.to_s, json_value(item)] }
39
- when Array then value.map { json_value(it) }
40
- when Symbol then value.to_s
41
- else value
42
- end
43
- end
44
- private_class_method :json_value
45
-
48
+ # Parses a snapshot back into runs. Providers are looked up by name, so
49
+ # the document's providers must still be configured.
50
+ #
46
51
  # @param source [String] report JSON, not a path
47
52
  # @param providers [Providers::Registry]
48
53
  # @return [Loaded]
54
+ # @raise [KeyError] when a required field is missing
49
55
  # @raise [Error] for an unknown format/version or provider
50
- def self.decode(source, providers:)
51
- document = ::JSON.parse(source)
52
- unless document["format"] == FORMAT && document["version"] == VERSION
53
- raise Error, "unsupported report format version (expected #{FORMAT} v#{VERSION})"
54
- end
55
-
56
- metrics = document.fetch("metrics").map do |metric|
57
- Metric.new(name: metric.fetch("name"),
58
- description: metric.fetch("description", ""),
59
- aggregate: metric.fetch("aggregate", "mean").to_sym,
60
- primary: metric.fetch("primary", false))
61
- end
62
- weights = document["weights"]
63
- weighted_score = WeightedScore.new(weights, metrics: metrics.map(&:name)) if weights && !weights.empty?
64
- Loaded.new(suite: document.fetch("suite", "llm-evals"),
65
- generated_at: document["generated_at"],
66
- run_options: document.fetch("run_options", {}),
67
- summaries: document.fetch("summaries").map { summary_from(it, providers) },
68
- metrics:,
69
- weighted_score:)
70
- rescue ::JSON::ParserError => e
71
- raise Error, "invalid report JSON: #{e.message}"
72
- end
56
+ def self.decode(source, providers:) = Decoder.new(providers:).call(source)
73
57
 
58
+ # Alias of {.decode}.
59
+ #
60
+ # @param source [String] report JSON, not a path
61
+ # @param providers [Providers::Registry]
62
+ # @return [Loaded]
63
+ # @raise [Error] for an unknown format/version or provider
74
64
  def self.load(source, providers:) = decode(source, providers:)
75
-
76
- def self.summary(summary)
77
- {
78
- model: { id: summary.model.id, provider: summary.model.provider.name, settings: summary.model.settings },
79
- dataset: summary.dataset,
80
- name: summary.name,
81
- means: summary.means,
82
- errors: summary.errors,
83
- duration: summary.duration,
84
- permalink: summary.permalink,
85
- usage: summary.usage.to_h,
86
- results: summary.results.map { result(it) }
87
- }
88
- end
89
- private_class_method :summary
90
-
91
- def self.result(result)
92
- {
93
- case: {
94
- id: result.case.id,
95
- dataset: result.case.dataset,
96
- variant: result.case.variant,
97
- input: result.case.input,
98
- expected: result.case.expected,
99
- metadata: result.case.metadata
100
- },
101
- reply: result.reply && {
102
- output: result.reply.output,
103
- raw: result.reply.raw,
104
- usage: result.reply.usage.to_h,
105
- error: result.reply.error,
106
- metadata: result.reply.metadata
107
- },
108
- score: result.score && {
109
- value: result.score.value,
110
- metrics: result.score.metrics,
111
- outcomes: result.score.outcomes,
112
- diagnostics: result.score.diagnostics
113
- },
114
- duration: result.duration,
115
- error: result.error
116
- }
117
- end
118
- private_class_method :result
119
-
120
- def self.summary_from(data, providers)
121
- model_data = data.fetch("model")
122
- provider = providers.find { it.name == model_data.fetch("provider") }
123
- raise Error, "report uses unknown provider #{model_data.fetch("provider").inspect}" unless provider
124
-
125
- model = Model.new(id: model_data.fetch("id"), provider:, settings: model_data.fetch("settings", {}))
126
- placeholder_cases = data.fetch("results").map do |result_data|
127
- case_data = result_data.fetch("case")
128
- kase = Case.new(id: case_data.fetch("id"),
129
- dataset: case_data.fetch("dataset"),
130
- variant: case_data.fetch("variant"),
131
- input: case_data.fetch("input"),
132
- expected: case_data["expected"],
133
- path: "",
134
- metadata: (case_data["metadata"] || {}).transform_keys(&:to_sym))
135
- [result_data, kase]
136
- end
137
- experiment = SavedExperiment.new(model:)
138
- results = placeholder_cases.map do |result_data, kase|
139
- reply_data = result_data["reply"]
140
- reply = if reply_data
141
- Reply.new(output: reply_data["output"],
142
- raw: reply_data["raw"],
143
- usage: reply_data["usage"],
144
- error: reply_data["error"],
145
- metadata: reply_data["metadata"] || {})
146
- end
147
- score_data = result_data["score"]
148
- if score_data
149
- score = Score.new(value: score_data.fetch("value"),
150
- metrics: score_data["metrics"] || {},
151
- outcomes: score_data["outcomes"] || {},
152
- diagnostics: score_data["diagnostics"] || {})
153
- end
154
- RunResult.new(experiment:, case: kase, reply:, score:, duration: result_data.fetch("duration", 0.0), error: result_data["error"])
155
- end
156
-
157
- Experiment::Summary.new(model:,
158
- dataset: data.fetch("dataset"),
159
- name: data.fetch("name"),
160
- means: data.fetch("means"),
161
- results:,
162
- errors: data.fetch("errors", []),
163
- duration: data.fetch("duration", 0.0),
164
- permalink: data["permalink"],
165
- usage: Usage.from(data["usage"]))
166
- end
167
- private_class_method :summary_from
168
65
  end
169
66
  end
170
67
  end
@@ -11,10 +11,25 @@ module LLM
11
11
  # Writes the canonical JSON snapshot and passes that snapshot to the
12
12
  # selected display. Temporary snapshots are removed after presentation.
13
13
  class Pipeline
14
+ # Where a run's reports went. A path is nil for anything not kept; `browser_opened`
15
+ # is nil when no attempt was made.
16
+ #
17
+ # @!attribute [r] json_path
18
+ # @return [String, nil] the saved snapshot; nil when it was only temporary
19
+ # @!attribute [r] html_path
20
+ # @return [String, nil]
21
+ # @!attribute [r] css_path
22
+ # @return [String, nil]
23
+ # @!attribute [r] js_path
24
+ # @return [String, nil]
25
+ # @!attribute [r] browser_opened
26
+ # @return [Boolean, nil]
14
27
  Result = Data.define(:json_path, :html_path, :css_path, :js_path, :browser_opened)
15
28
 
29
+ # Reduces the project's name to a slug for use in file names.
30
+ #
16
31
  # @param providers [Providers::Registry]
17
- # @param runtime [Runtime]
32
+ # @param runtime [Runtime::Environment]
18
33
  # @param destination [String]
19
34
  # @param temp_dir [String]
20
35
  # @param project [String]
@@ -26,14 +41,20 @@ module LLM
26
41
  @project = project.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
27
42
  end
28
43
 
29
- # @param summaries [Array<Experiment::Summary>]
30
- # @param metrics [Array<Metric>]
31
- # @param weighted_score [WeightedScore, nil]
44
+ # Decodes the snapshot back before showing it, so a live report and `render`
45
+ # display exactly the same thing. Does nothing without autosave or a display.
46
+ # `display` is none, tui, browser or both.
47
+ #
48
+ # @param summaries [Array<Actions::Experiment::Summary>]
49
+ # @param metrics [Array<Scoring::Metric>]
50
+ # @param weighted_score [Scoring::WeightedScore, nil]
32
51
  # @param run_options [Hash]
33
52
  # @param autosave [Boolean]
34
53
  # @param display [String]
35
54
  # @param open [Boolean]
36
55
  # @yield [report] display the decoded JSON in the terminal
56
+ # @yieldparam report [JSON::Loaded]
57
+ # @raise [Error] when the snapshot cannot be decoded
37
58
  # @return [Result]
38
59
  def call(summaries:, metrics:, weighted_score:, run_options:, autosave:, display:, open:)
39
60
  return Result.new(nil, nil, nil, nil, nil) unless autosave || display != "none"
@@ -44,24 +65,18 @@ module LLM
44
65
  return Result.new(json_path:, html_path: nil, css_path: nil, js_path: nil, browser_opened: nil) if display == "none"
45
66
 
46
67
  report = JSON.decode(File.read(json_path), providers: @providers)
47
- assets = case display
48
- when "tui"
49
- yield report if block_given?
50
- [nil, nil, nil, nil]
51
- when "browser"
52
- render_browser(report, open:)
53
- else
54
- [nil, nil, nil, nil]
55
- end
56
- html_path, css_path, js_path, browser_opened = assets
68
+ yield report if block_given? && Config::TERMINAL_DISPLAYS.include?(display)
69
+ html_path, css_path, js_path, browser_opened = render_browser(report, open:) if Config::BROWSER_DISPLAYS.include?(display)
57
70
  Result.new(json_path: autosave ? json_path : nil, html_path:, css_path:, js_path:, browser_opened:)
58
71
  ensure
59
72
  FileUtils.rm_f(json_path) if defined?(json_path) && json_path && !autosave
60
73
  end
61
74
 
62
- # @param summaries [Array<Experiment::Summary>]
63
- # @param metrics [Array<Metric>]
64
- # @param weighted_score [WeightedScore, nil]
75
+ # The snapshot's document, to a file or back to the caller.
76
+ #
77
+ # @param summaries [Array<Actions::Experiment::Summary>]
78
+ # @param metrics [Array<Scoring::Metric>]
79
+ # @param weighted_score [Scoring::WeightedScore, nil]
65
80
  # @param run_options [Hash]
66
81
  # @param path [String, nil] nil returns JSON text to the caller
67
82
  # @return [String, nil] JSON text when no path was supplied
@@ -76,6 +91,8 @@ module LLM
76
91
  end
77
92
  end
78
93
 
94
+ # Rebuilds the HTML from a snapshot, standing alone with its assets embedded.
95
+ #
79
96
  # @param json [String] serialized run results
80
97
  # @param path [String, nil] nil returns HTML text to the caller
81
98
  # @return [String, nil] HTML text when no path was supplied
@@ -94,9 +111,9 @@ module LLM
94
111
  # Writes an explicitly requested JSON report under the configured
95
112
  # destination, using the same timestamped name as an autosaved result.
96
113
  #
97
- # @param summaries [Array<Experiment::Summary>]
98
- # @param metrics [Array<Metric>]
99
- # @param weighted_score [WeightedScore, nil]
114
+ # @param summaries [Array<Actions::Experiment::Summary>]
115
+ # @param metrics [Array<Scoring::Metric>]
116
+ # @param weighted_score [Scoring::WeightedScore, nil]
100
117
  # @param run_options [Hash]
101
118
  # @return [String] the saved JSON path
102
119
  def save_json(summaries:, metrics:, weighted_score:, run_options:)
@@ -118,6 +135,12 @@ module LLM
118
135
 
119
136
  private
120
137
 
138
+ # Writes the page and its assets next to each other, and opens it when asked.
139
+ #
140
+ # @param report [JSON::Loaded]
141
+ # @param open [Boolean] whether to open the page in the browser
142
+ # @return [Array(String, String, String, Boolean, nil)] the HTML, CSS and JS paths, and
143
+ # whether the browser opened; nil when `open` is false
121
144
  def render_browser(report, open:)
122
145
  FileUtils.mkdir_p(@destination)
123
146
  html_path = report_path(@destination, "html")
@@ -133,12 +156,24 @@ module LLM
133
156
  [html_path, css_path, js_path, browser_opened]
134
157
  end
135
158
 
159
+ # Timestamped, so results accumulate rather than replace each other.
160
+ #
161
+ # @param directory [String]
162
+ # @param stamp [String] see {#timestamp}
163
+ # @param extension [String]
164
+ # @return [String]
136
165
  def artifact_path(directory, stamp, extension)
137
166
  File.join(directory, "#{@project}.results.#{stamp}.#{extension}")
138
167
  end
139
168
 
169
+ # Down to the microsecond, so runs started together do not overwrite each other.
170
+ #
171
+ # @return [String]
140
172
  def timestamp = Time.now.strftime("%Y-%m-%d.%H%M%S%6N")
141
173
 
174
+ # Creates an empty file now to claim the name; {#call} removes it afterwards.
175
+ #
176
+ # @return [String] the path
142
177
  def temporary_json_path
143
178
  FileUtils.mkdir_p(@temp_dir)
144
179
  file = Tempfile.create(["#{@project}.results.", ".json"], @temp_dir)
@@ -7,6 +7,8 @@ module LLM
7
7
  # Presents a decoded report in the terminal and optionally writes the
8
8
  # detailed view to a report file.
9
9
  class Terminal
10
+ # The collaborators are callables so the command keeps its drawing methods.
11
+ #
10
12
  # @param invocation [Dry::CLI::UI::Invocation]
11
13
  # @param writing_to [#call] redirects the command's table helpers
12
14
  # @param leaderboard [#call] draws the summary leaderboard
@@ -16,11 +18,16 @@ module LLM
16
18
  @leaderboard = leaderboard
17
19
  end
18
20
 
19
- # @param summaries [Array<Experiment::Summary>]
21
+ # Draws the detail then the leaderboard. With a report file, both also go there;
22
+ # the screen then gets the leaderboard alone.
23
+ #
24
+ # @param summaries [Array<Actions::Experiment::Summary>]
20
25
  # @param output [String, nil]
21
26
  # @param scores [Boolean]
22
27
  # @param totals_mode [String] overall, variant or both
28
+ # @param details [Proc, nil] the block below
23
29
  # @yield [summaries] draw the per-case detail
30
+ # @yieldparam summaries [Array<Actions::Experiment::Summary>]
24
31
  # @return [String, nil] report file path
25
32
  def call(summaries:, output:, scores:, totals_mode:, &details)
26
33
  path = @invocation.report_path(output)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Reports on completed runs, built from a saved snapshot.
6
+ module Render
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Results
6
+ # What a {Models::Model} returns from one call: the text, what it cost, and the
7
+ # provider's raw response for an adapter that needs more than the text.
8
+ #
9
+ # @!attribute [r] text
10
+ # @return [String] the model's answer, never nil
11
+ # @!attribute [r] usage
12
+ # @return [Stats::Usage] what the call cost
13
+ # @!attribute [r] raw
14
+ # @return [RubyLLM::Message, Hash, nil] the provider's own response: OpenRouter's message, TypeSafe's answers by name
15
+ ProviderResponse = Data.define(:text, :usage, :raw) do
16
+ # Normalizes the text to a String and the usage to {Stats::Usage}.
17
+ #
18
+ # @param text [#to_s] the model's answer
19
+ # @param usage [Stats::Usage, Hash, nil] what the call cost; see {Stats::Usage.from}
20
+ # @param raw [RubyLLM::Message, Hash, nil] the provider's own response
21
+ def initialize(text:, usage: Stats::Usage.zero, raw: nil) = super(text: text.to_s, usage: Stats::Usage.from(usage), raw: raw)
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Results
6
+ # What an {Actions::Adapter} returns for one case. `error` is the model's failure
7
+ # (it did not answer the question: no JSON, a refusal) and scores 0; a
8
+ # transport failure is not the model's and is raised instead, so it is
9
+ # counted as an error of the run rather than a wrong answer.
10
+ #
11
+ # @!attribute [r] output
12
+ # @return [Object, nil] the adapter's parsed answer, usually a Hash; nil when the model failed
13
+ # @!attribute [r] raw
14
+ # @return [String, nil] the reply as the model gave it
15
+ # @!attribute [r] usage
16
+ # @return [Stats::Usage] what the call cost
17
+ # @!attribute [r] error
18
+ # @return [String, nil] why the model failed to answer
19
+ # @!attribute [r] metadata
20
+ # @return [Hash] anything else the adapter wants kept with the reply
21
+ Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
22
+ # Normalizes the usage, so a Hash read back from a snapshot is accepted.
23
+ #
24
+ # @param output [Object, nil] the adapter's parsed answer
25
+ # @param raw [String, nil] the reply as the model gave it
26
+ # @param usage [Stats::Usage, Hash, nil] what the call cost; see {Stats::Usage.from}
27
+ # @param error [String, nil] why the model failed to answer
28
+ # @param metadata [Hash] anything else the adapter wants kept with the reply
29
+ def initialize(output: nil, raw: nil, usage: Stats::Usage.zero, error: nil, metadata: {})
30
+ super(output: output, raw: raw, usage: Stats::Usage.from(usage), error: error, metadata: metadata)
31
+ end
32
+
33
+ # @return [Boolean] whether the model failed to answer
34
+ def error? = !error.nil?
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Results
6
+ # What running a set of experiments produced, and how long it took as a whole.
7
+ #
8
+ # @!attribute [r] summaries
9
+ # @return [Array<Actions::Experiment::Summary>] the experiments that finished
10
+ # @!attribute [r] seconds
11
+ # @return [Float] wall-clock time for the whole run, which experiments overlap within
12
+ RunOutcome = Data.define(:summaries, :seconds)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Results
6
+ # One case, run once: what came back and what it scored. `reply` is nil
7
+ # when the adapter raised, and `error` then holds the message.
8
+ #
9
+ # @!attribute [r] experiment
10
+ # @return [Actions::Experiment, Render::Reports::JSON::SavedExperiment] what ran it; the latter when read from a snapshot
11
+ # @!attribute [r] case
12
+ # @return [Datasets::Case] the case that ran
13
+ # @!attribute [r] reply
14
+ # @return [Reply, nil] what the adapter returned; nil when it raised
15
+ # @!attribute [r] score
16
+ # @return [Score, nil] what the reply scored; nil when there is nothing to score
17
+ # @!attribute [r] duration
18
+ # @return [Float] seconds this case took
19
+ # @!attribute [r] error
20
+ # @return [String, nil] the message of what the adapter raised
21
+ RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
22
+ # Only `experiment` and `case` are required, since a failed run has little else.
23
+ #
24
+ # @param experiment [Actions::Experiment, Render::Reports::JSON::SavedExperiment] what ran it
25
+ # @param case [Datasets::Case] the case that ran
26
+ # @param reply [Reply, nil] what the adapter returned
27
+ # @param score [Score, nil] what the reply scored
28
+ # @param duration [Float] seconds this case took
29
+ # @param error [String, nil] the message of what the adapter raised
30
+ def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
31
+
32
+ # @return [Boolean] whether the adapter raised rather than answered
33
+ def failed? = !error.nil?
34
+
35
+ # @return [Boolean] whether the case went wrong, by raising or by a reply the model failed
36
+ def errored? = failed? || reply&.error? == true
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Results
6
+ # What a {Scoring::Scorer} says about one reply. `value` is the case's overall
7
+ # score in 0..1; `metrics` holds one entry per {Scoring::Metric} the scorer
8
+ # declares, nil where the case does not apply; `outcomes` and
9
+ # `diagnostics` are free-form, keyed by field, and only ever displayed.
10
+ #
11
+ # @!attribute [r] value
12
+ # @return [Float] the overall score, in 0..1
13
+ # @!attribute [r] metrics
14
+ # @return [Hash{String => Float, nil}] by metric name
15
+ # @!attribute [r] outcomes
16
+ # @return [Hash] free-form, keyed by field; only displayed
17
+ # @!attribute [r] diagnostics
18
+ # @return [Hash] free-form, keyed by field; only displayed
19
+ Score = Data.define(:value, :metrics, :outcomes, :diagnostics) do
20
+ # Refuses a value outside 0..1, and turns metric names into Strings so that
21
+ # a fresh score and one read back from JSON look up the same way.
22
+ #
23
+ # @param value [Numeric] the overall score, in 0..1
24
+ # @param metrics [Hash{String, Symbol => Float, nil}] by metric name
25
+ # @param outcomes [Hash] free-form, keyed by field
26
+ # @param diagnostics [Hash] free-form, keyed by field
27
+ # @raise [ArgumentError] when the value is not a number within 0..1
28
+ def initialize(value:, metrics: {}, outcomes: {}, diagnostics: {})
29
+ raise ArgumentError, "score value #{value.inspect} is not within 0..1" unless value.is_a?(Numeric) && (0..1).cover?(value)
30
+
31
+ super(value: value.to_f, metrics: metrics.transform_keys(&:to_s), outcomes: outcomes, diagnostics: diagnostics)
32
+ end
33
+
34
+ # The score of a reply the model failed: 0 everywhere, the failure as a diagnostic.
35
+ #
36
+ # @param metrics [Array<Scoring::Metric>]
37
+ # @param error [String]
38
+ # @return [Score]
39
+ def self.failed(metrics, error)
40
+ new(value: 0.0, metrics: metrics.to_h { [it.name, 0.0] }, diagnostics: { "error" => error })
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end