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
@@ -11,16 +11,24 @@ module LLM
11
11
  # `wrong` maps a case to fields to answer wrongly. `answers`, a callable
12
12
  # taking the user prompt, replaces the lookup altogether.
13
13
  class FakeProvider < Providers::Base
14
+ # The namespace of the ids this provider claims.
14
15
  PREFIX = "fake/"
15
- USAGE = Usage.new(input_tokens: 100, output_tokens: 10, cost_usd: 0.001)
16
+ # What every call reports having cost, so totals are predictable in a spec.
17
+ USAGE = Stats::Usage.new(input_tokens: 100, output_tokens: 10, cost_usd: 0.001)
18
+ # The reply a `junk` case gets.
16
19
  JUNK = "Sure! Here is my answer, in prose rather than JSON."
17
20
 
21
+ # Every {#complete} call so far, with `model:`, `system:`, `user:` and `json:`, for a spec to inspect.
22
+ #
23
+ # @return [Array<Hash{Symbol => Object}>]
18
24
  attr_reader :calls
19
25
 
26
+ # With no arguments, every case gets its expected JSON.
27
+ #
20
28
  # @param junk [Array<String>] case ids or keys to answer with prose
21
29
  # @param wrong [Hash{String => Hash}] case id or key => fields to override
22
30
  # @param answers [#call, nil] user prompt => reply text; replaces the lookup
23
- # @param discovery [Discovery, nil] where to look cases up; the configured suite's when nil
31
+ # @param discovery [Datasets::Discovery, nil] where to look cases up; the configured suite's when nil
24
32
  def initialize(junk: [], wrong: {}, answers: nil, discovery: nil)
25
33
  super()
26
34
  @junk = junk
@@ -31,26 +39,46 @@ module LLM
31
39
  @lock = Mutex.new
32
40
  end
33
41
 
42
+ # Overrides {Base#name}.
43
+ #
44
+ # @return [String] "fake"
34
45
  def name = "fake"
46
+ # Overrides {Base#request_type}.
47
+ #
48
+ # @return [Symbol] `:prompt`
35
49
  def request_type = :prompt
50
+ # Overrides {Base#prefix}.
51
+ #
52
+ # @return [String] {PREFIX}
36
53
  def prefix = PREFIX
54
+ # Overrides {Base#env_keys}: nothing is called, so nothing is needed.
55
+ #
56
+ # @return [Array<String>] always empty
37
57
  def env_keys = []
38
58
 
59
+ # Overrides {Base#catalog} to say so outright.
60
+ #
39
61
  # @return [nil] any id passes verification
40
62
  def catalog = nil
41
63
 
42
- # @param model [Model]
43
- # @param system [String, nil]
44
- # @param user [String]
45
- # @param json [Boolean]
46
- # @return [Completion]
64
+ # Records the call under a lock, then answers it. Overrides {Base#complete}.
65
+ #
66
+ # @param model [Models::Model] the model called
67
+ # @param system [String, nil] the system prompt, recorded only
68
+ # @param user [String] the user prompt, which selects the case
69
+ # @param json [Boolean] recorded only
70
+ # @return [Results::ProviderResponse]
47
71
  def complete(model, system:, user:, json: true)
48
72
  @lock.synchronize { @calls << { model: model.id, system: system, user: user, json: json } }
49
- Completion.new(text: @answers ? @answers.call(user) : answer(user), usage: USAGE)
73
+ Results::ProviderResponse.new(text: @answers ? @answers.call(user) : answer(user), usage: USAGE)
50
74
  end
51
75
 
52
76
  private
53
77
 
78
+ # Looks the case up by its input. A prompt matching no case gets "{}".
79
+ #
80
+ # @param user [String] the user prompt
81
+ # @return [String] prose for a `junk` case; otherwise JSON, with `wrong` merged in
54
82
  def answer(user)
55
83
  kase = discovery.all.find { it.input == user }
56
84
  return "{}" if kase.nil?
@@ -60,6 +88,9 @@ module LLM
60
88
  JSON.generate(kase.expected.is_a?(Hash) ? kase.expected.merge(wrong) : kase.expected)
61
89
  end
62
90
 
91
+ # The injected discovery, or the configured suite's when none was given.
92
+ #
93
+ # @return [Datasets::Discovery]
63
94
  def discovery = @discovery || LLM::Evals.configuration.discovery
64
95
  end
65
96
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Doubles a suite can run without calling anything.
6
+ module Testing
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Trackers
6
+ # Where a run's results go besides the terminal. The gem always owns the
7
+ # run loop; a tracker only receives callbacks: {#start} once per
8
+ # experiment, {#record} once per finished case, {#finish} once with the
9
+ # summary. The local JSON log ({Trackers::Local}) always runs; at most
10
+ # one remote tracker runs beside it, chosen by `--tracker`, the
11
+ # `tracker:` key, or the environment (see {Trackers::Registry#choose}).
12
+ #
13
+ # A tracker for another framework (W&B Weave, an OTLP backend) subclasses
14
+ # this in a consumer or a plugin gem and is registered with `c.tracker`.
15
+ #
16
+ # @abstract Override the three callbacks; declare {.env_key} and {.gems}.
17
+ class Base
18
+ class << self
19
+ # The class name is the default, so a subclass needs no declaration.
20
+ #
21
+ # @return [String] the name `--tracker` and `tracker:` use; the class name, dasherized, by default
22
+ def tracker_name = (name || "Tracker").split("::").last.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase
23
+
24
+ # Nil means the environment can never select this tracker by itself.
25
+ #
26
+ # @return [String, nil] the environment variable whose presence selects this tracker when nothing else does
27
+ def env_key = nil
28
+
29
+ # Loaded by {.requirements!} rather than at require time.
30
+ #
31
+ # @return [Array<String>] the optional gems this tracker requires when chosen
32
+ def gems = []
33
+
34
+ # A blank value counts as absent.
35
+ #
36
+ # @param env [#[]] the environment, ENV by default
37
+ # @return [Boolean] whether the environment names this tracker
38
+ def detect?(env = ENV) = !env_key.nil? && !env[env_key].to_s.strip.empty?
39
+
40
+ # Loads the gems and checks the key, with an error that names both, so
41
+ # a chosen tracker that cannot run says so before any model is called.
42
+ #
43
+ # @param env [#[]] the environment, ENV by default
44
+ # @return [void]
45
+ # @raise [MissingDependency] when a gem is absent
46
+ # @raise [Error] when the key is not set
47
+ def requirements!(env = ENV)
48
+ gems.each do |gem_name|
49
+ LLM::Evals.optional!(gem_name.tr("-", "/"), gem_name, needed_for: "the #{tracker_name} tracker")
50
+ end
51
+ return if env_key.nil? || detect?(env)
52
+
53
+ raise Error, "the #{tracker_name} tracker needs #{env_key} in the environment (gems: #{gems.join(", ")})"
54
+ end
55
+ end
56
+
57
+ # What {#initialize} was given, for subclasses to read.
58
+ #
59
+ # @return [Hash{Symbol => Object}]
60
+ attr_reader :context
61
+
62
+ # Keeps the context whole, so a subclass reads only what it wants.
63
+ #
64
+ # @param context [Hash] `suite:`, `project:`, `project_id:` and `options:` (the tracker's own key in the config file)
65
+ def initialize(**context)
66
+ @context = context
67
+ end
68
+
69
+ # Delegates to {.tracker_name}, so an instance and its class agree.
70
+ #
71
+ # @return [String]
72
+ def name = self.class.tracker_name
73
+
74
+ # Nothing in this class turns itself off; a subclass may.
75
+ #
76
+ # @return [Boolean] false turns every callback into a no-op
77
+ def enabled? = true
78
+
79
+ # Overridden by each tracker to name its destination.
80
+ #
81
+ # @return [String] one line for `--verbose`: where this tracker sends things, and why
82
+ def describe = name
83
+
84
+ # Called once per experiment, before any case runs.
85
+ #
86
+ # @param _experiment [Actions::Experiment] the experiment starting
87
+ # @return [void]
88
+ def start(_experiment) = nil
89
+
90
+ # Called once per finished case, one at a time under the experiment's lock.
91
+ #
92
+ # @param _result [Results::RunResult] the finished case
93
+ # @return [void]
94
+ def record(_result) = nil
95
+
96
+ # Called once per experiment, after the last case.
97
+ #
98
+ # @param _experiment [Actions::Experiment] the experiment finishing
99
+ # @param _summary [Actions::Experiment::Summary] its totals
100
+ # @return [Hash] `url:` to the experiment when one is known, else nil
101
+ def finish(_experiment, _summary) = { url: nil }
102
+
103
+ # Empty when the config file has no section for this tracker.
104
+ #
105
+ # @return [Hash] the tracker's own section of the config file
106
+ def options = context.fetch(:options, nil) || {}
107
+ end
108
+ end
109
+ end
110
+ end
@@ -7,17 +7,38 @@ module LLM
7
7
  # suite's project, through the braintrust gem's own OpenTelemetry
8
8
  # exporter, the way `Braintrust::Eval.run` would have, except that the
9
9
  # loop stays ours. The gem is required only when this tracker is chosen.
10
- class Braintrust < Tracker
10
+ class Braintrust < Base
11
+ # A set id skips the project lookup in {#start}.
12
+ #
13
+ # @param env [#[]]
14
+ # @return [String, nil] the project BRAINTRUST_PROJECT_ID names
15
+ def self.project_id(env = ENV) = env["BRAINTRUST_PROJECT_ID"].to_s[/\S+/]
16
+
17
+ # Overrides {Base.env_key}.
18
+ #
19
+ # @return [String] "BRAINTRUST_API_KEY"
11
20
  def self.env_key = "BRAINTRUST_API_KEY"
21
+ # Overrides {Base.gems}.
22
+ #
23
+ # @return [Array<String>] just the braintrust gem
12
24
  def self.gems = ["braintrust"]
13
25
 
14
26
  attr_reader :experiment_id, :permalink
15
27
 
28
+ # @!attribute [r] experiment_id
29
+ # @return [String, nil] the Braintrust experiment; nil until {#start}
30
+
31
+ # @!attribute [r] permalink
32
+ # @return [String, nil] the experiment's page; nil until {#start}
33
+
34
+ # Overrides {Base#describe}.
35
+ #
36
+ # @return [String] the destination project, by id when there is one
16
37
  def describe = "braintrust: project #{context[:project_id] || context[:project].inspect}"
17
38
 
18
39
  # Logs in, finds or creates the project, creates the experiment.
19
40
  #
20
- # @param experiment [Experiment]
41
+ # @param experiment [Actions::Experiment]
21
42
  # @return [void]
22
43
  def start(experiment)
23
44
  ::Braintrust.init(blocking_login: true, auto_instrument: false)
@@ -41,7 +62,7 @@ module LLM
41
62
  # runner emits: input, expected, output, scores and metadata as JSON
42
63
  # attributes, parented to the experiment.
43
64
  #
44
- # @param result [RunResult]
65
+ # @param result [Results::RunResult]
45
66
  # @return [void]
46
67
  def record(result)
47
68
  span = @tracer.start_root_span("eval")
@@ -63,6 +84,8 @@ module LLM
63
84
 
64
85
  # Flushes the spans and hands back the experiment's page.
65
86
  #
87
+ # @param _experiment [Actions::Experiment] unused
88
+ # @param _summary [Actions::Experiment::Summary] unused
66
89
  # @return [Hash] `url:` the experiment permalink
67
90
  def finish(_experiment, _summary)
68
91
  provider = OpenTelemetry.tracer_provider
@@ -72,13 +95,27 @@ module LLM
72
95
 
73
96
  private
74
97
 
98
+ # Sets one span attribute as JSON, the form Braintrust reads.
99
+ #
100
+ # @param span [OpenTelemetry::Trace::Span] the span to annotate
101
+ # @param key [String] the attribute name
102
+ # @param value [Object] any JSON-encodable value
103
+ # @return [void]
75
104
  def json(span, key, value) = span.set_attribute(key, JSON.dump(value))
76
105
 
106
+ # What the experiment was run with, as Braintrust stores it.
107
+ #
108
+ # @param experiment [Actions::Experiment] the experiment starting
109
+ # @return [Hash{Symbol => Object}]
77
110
  def metadata(experiment)
78
111
  { model: experiment.model.id, provider: experiment.model.provider.name, dataset: experiment.dataset,
79
112
  **experiment.model.settings, **experiment.adapter.metadata }
80
113
  end
81
114
 
115
+ # What a case span carries beyond its scores; a failed case has no score or reply to report.
116
+ #
117
+ # @param result [Results::RunResult] the finished case
118
+ # @return [Hash{Symbol => Object}]
82
119
  def case_metadata(result)
83
120
  base = { case: result.case.key, dataset: result.case.dataset, variant: result.case.variant, difficulty: result.case.difficulty }
84
121
  return base.merge(error: result.error) if result.failed?
@@ -8,11 +8,19 @@ module LLM
8
8
  # Always on: every experiment's results and summary land in one JSON
9
9
  # file under `log/`, so a run is never lost to a closed terminal or a
10
10
  # remote tracker that was never configured.
11
- class Local < Tracker
11
+ class Local < Base
12
12
  attr_reader :dir, :path
13
13
 
14
+ # @!attribute [r] dir
15
+ # @return [String] where the files go
16
+
17
+ # @!attribute [r] path
18
+ # @return [String, nil] the file for this experiment; nil until {#start}
19
+
20
+ # Holds results in memory and writes nothing until {#finish}.
21
+ #
14
22
  # @param dir [String] where the files go; `log/llm-evals` at the repository root by default
15
- # @param context [Hash] see {Tracker#initialize}
23
+ # @param context [Hash] see {Base#initialize}
16
24
  def initialize(dir: File.join(Dry::CLI::UI::Invocation.root(Dir.pwd), "log", "llm-evals"), **context)
17
25
  super(**context)
18
26
  @dir = dir
@@ -20,17 +28,32 @@ module LLM
20
28
  @lock = Mutex.new
21
29
  end
22
30
 
31
+ # Overrides {Base#describe}.
32
+ #
33
+ # @return [String] the directory
23
34
  def describe = "local: #{dir}"
24
35
 
36
+ # Fixes the file name from the start time, so {#finish} needs no clock.
37
+ #
38
+ # @param experiment [Actions::Experiment] the experiment starting
39
+ # @return [void]
25
40
  def start(experiment)
26
41
  @started_at = Time.now
27
42
  @path = File.join(dir, "#{slug(experiment.name)}.#{@started_at.strftime("%Y-%m-%d.%H%M%S")}.json")
28
43
  end
29
44
 
45
+ # Keeps the result for {#finish}'s document.
46
+ #
47
+ # @param result [Results::RunResult] the finished case
48
+ # @return [void]
30
49
  def record(result)
31
50
  @lock.synchronize { @results << result }
32
51
  end
33
52
 
53
+ # Creates the directory and writes the whole document in one go.
54
+ #
55
+ # @param experiment [Actions::Experiment] the experiment finishing
56
+ # @param summary [Actions::Experiment::Summary] its totals
34
57
  # @return [Hash] `url:` nil, `path:` the file written
35
58
  def finish(experiment, summary)
36
59
  FileUtils.mkdir_p(dir)
@@ -40,8 +63,17 @@ module LLM
40
63
 
41
64
  private
42
65
 
66
+ # Makes a name safe for a file name.
67
+ #
68
+ # @param text [String] the experiment's name
69
+ # @return [String]
43
70
  def slug(text) = text.gsub(/[^A-Za-z0-9.-]+/, "_").squeeze("_").delete_prefix("_").delete_suffix("_")
44
71
 
72
+ # The file's whole contents, with results back in case order rather than finishing order.
73
+ #
74
+ # @param experiment [Actions::Experiment] the experiment finishing
75
+ # @param summary [Actions::Experiment::Summary] its totals
76
+ # @return [Hash{Symbol => Object}]
45
77
  def document(experiment, summary)
46
78
  {
47
79
  suite: context[:suite],
@@ -59,6 +91,10 @@ module LLM
59
91
  }
60
92
  end
61
93
 
94
+ # One case's entry; the score and reply are nil when the case raised.
95
+ #
96
+ # @param result [Results::RunResult] the finished case
97
+ # @return [Hash{Symbol => Object}]
62
98
  def row(result)
63
99
  {
64
100
  case: result.case.key,
@@ -14,27 +14,45 @@ module LLM
14
14
  # already set, the SDK's own reading of it wins and {#describe} says so.
15
15
  # The raw reply is not sent unless the config file opts in with
16
16
  # `logfire: { include_replies: true }`.
17
- class Logfire < Tracker
17
+ class Logfire < Base
18
18
  # Logfire's US region, per their docs on 2026-10-15; other regions
19
19
  # exist, so LOGFIRE_BASE_URL overrides it.
20
20
  DEFAULT_BASE_URL = "https://logfire-us.pydantic.dev"
21
+ # The environment variable holding the write token; also what selects this tracker.
21
22
  TOKEN = "LOGFIRE_TOKEN"
23
+ # The environment variable that overrides {DEFAULT_BASE_URL}.
22
24
  BASE_URL = "LOGFIRE_BASE_URL"
25
+ # The standard OTLP endpoint variable, which beats ours when set.
23
26
  OTEL_ENDPOINT = "OTEL_EXPORTER_OTLP_ENDPOINT"
27
+ # The standard OTLP headers variable, which beats ours when set.
24
28
  OTEL_HEADERS = "OTEL_EXPORTER_OTLP_HEADERS"
25
29
 
26
30
  # Bytes of the diagnostics JSON kept on a case span; the rest is cut,
27
31
  # since an attribute is not the place for a whole transcript.
28
32
  DIAGNOSTICS_LIMIT = 4096
29
33
 
34
+ # Overrides {Base.env_key}.
35
+ #
36
+ # @return [String] {TOKEN}
30
37
  def self.env_key = TOKEN
38
+ # Overrides {Base.gems}.
39
+ #
40
+ # @return [Array<String>] the OpenTelemetry SDK and its OTLP exporter
31
41
  def self.gems = %w[opentelemetry-sdk opentelemetry-exporter-otlp]
32
42
 
33
43
  attr_reader :endpoint, :source
34
44
 
45
+ # @!attribute [r] endpoint
46
+ # @return [String, nil] where traces go; nil leaves the SDK's default
47
+
48
+ # @!attribute [r] source
49
+ # @return [String] which variables the credentials came from, for {#describe}
50
+
51
+ # Reads the settings now; the OpenTelemetry objects wait for {#start}.
52
+ #
35
53
  # @param exporter [#export, nil] an exporter to use instead of OTLP, for tests
36
- # @param env [Hash]
37
- # @param context [Hash] see {Tracker#initialize}
54
+ # @param env [#[]] the environment, ENV by default
55
+ # @param context [Hash] see {Base#initialize}
38
56
  def initialize(exporter: nil, env: ENV, **context)
39
57
  super(**context)
40
58
  @env = env
@@ -43,17 +61,31 @@ module LLM
43
61
  @cases = []
44
62
  end
45
63
 
64
+ # Overrides {Base#describe}.
65
+ #
66
+ # @return [String] the endpoint and where the credentials came from
46
67
  def describe = "logfire: endpoint #{endpoint || "(SDK default)"}, credentials from #{source}"
47
68
 
69
+ # Accepts the config's key as a string or a symbol.
70
+ #
48
71
  # @return [Boolean] whether raw replies ride along on the case spans
49
72
  def include_replies? = options["include_replies"] == true || options[:include_replies] == true
50
73
 
74
+ # Builds the provider and opens the experiment span.
75
+ #
76
+ # @param experiment [Actions::Experiment] the experiment starting
77
+ # @return [void]
51
78
  def start(experiment)
52
79
  @provider = provider
53
80
  @tracer = @provider.tracer("llm-evals", LLM::Evals::VERSION)
54
81
  @span = @tracer.start_span("experiment #{experiment.name}", attributes: experiment_attributes(experiment))
55
82
  end
56
83
 
84
+ # Adds a finished child span, back-dated by the case's duration, since the
85
+ # callback arrives only once the case is over.
86
+ #
87
+ # @param result [Results::RunResult] the finished case
88
+ # @return [void]
57
89
  def record(result)
58
90
  now = Time.now
59
91
  span = @tracer.start_span("case #{result.case.key}",
@@ -70,6 +102,8 @@ module LLM
70
102
  # Closes the experiment span with the summary and flushes. No URL:
71
103
  # Logfire's trace pages are not something the token lets us build.
72
104
  #
105
+ # @param _experiment [Actions::Experiment] unused
106
+ # @param summary [Actions::Experiment::Summary] its totals
73
107
  # @return [Hash] `url:` nil
74
108
  def finish(_experiment, summary)
75
109
  summary.means.each { |metric, mean| @span.set_attribute("evals.mean.#{metric}", mean) unless mean.nil? }
@@ -87,6 +121,10 @@ module LLM
87
121
  # Which credentials win: the standard OTEL_* variables when either is
88
122
  # set, else our LOGFIRE_* pair. Returns the endpoint and headers to
89
123
  # hand the exporter (nil leaves the SDK's own reading in place).
124
+ #
125
+ # @param env [#[]] the environment
126
+ # @return [Array(String, Hash{String => String}, String)] endpoint, headers and a
127
+ # description of the source; endpoint and headers may be nil
90
128
  def settings(env)
91
129
  if [OTEL_ENDPOINT, OTEL_HEADERS].any? { !env[it].to_s.empty? }
92
130
  [env[OTEL_ENDPOINT], nil, "#{OTEL_ENDPOINT}/#{OTEL_HEADERS} (set in the environment; #{TOKEN} ignored)"]
@@ -98,6 +136,8 @@ module LLM
98
136
 
99
137
  # A tracer provider of our own, so the global one, which a consumer
100
138
  # may have configured for its application, is left alone.
139
+ #
140
+ # @return [OpenTelemetry::SDK::Trace::TracerProvider]
101
141
  def provider
102
142
  exporter = @exporter || OpenTelemetry::Exporter::OTLP::Exporter.new(**{ endpoint: endpoint, headers: @headers }.compact)
103
143
  processor = if @exporter
@@ -109,6 +149,10 @@ module LLM
109
149
  OpenTelemetry::SDK::Trace::TracerProvider.new(resource: resource).tap { it.add_span_processor(processor) }
110
150
  end
111
151
 
152
+ # The attributes of the experiment span.
153
+ #
154
+ # @param experiment [Actions::Experiment] the experiment starting
155
+ # @return [Hash{String => String, Numeric}]
112
156
  def experiment_attributes(experiment)
113
157
  model = experiment.model
114
158
  { "evals.suite" => context[:suite].to_s, "evals.project" => context[:project].to_s, "evals.experiment" => experiment.name,
@@ -117,6 +161,11 @@ module LLM
117
161
  "evals.cases" => experiment.cases.size }
118
162
  end
119
163
 
164
+ # The attributes of a case span. A failed case has no score or reply, so it
165
+ # stops at the error.
166
+ #
167
+ # @param result [Results::RunResult] the finished case
168
+ # @return [Hash{String => String, Numeric}]
120
169
  def case_attributes(result)
121
170
  kase = result.case
122
171
  attributes = { "evals.case" => kase.id, "evals.dataset" => kase.dataset, "evals.variant" => kase.variant,
@@ -136,6 +185,10 @@ module LLM
136
185
  attributes
137
186
  end
138
187
 
188
+ # Caps an attribute at {DIAGNOSTICS_LIMIT} bytes.
189
+ #
190
+ # @param text [String] the JSON or reply text
191
+ # @return [String] the text, cut and marked with "..." when over the limit
139
192
  def truncate(text) = text.bytesize > DIAGNOSTICS_LIMIT ? "#{text.byteslice(0, DIAGNOSTICS_LIMIT)}..." : text
140
193
  end
141
194
  end
@@ -15,26 +15,38 @@ module LLM
15
15
  # @return [Registry] Braintrust and Logfire
16
16
  def self.default = new([Braintrust, Logfire])
17
17
 
18
- # @param classes [Array<Class<Tracker>>]
18
+ # The array is copied, so registering later does not touch the caller's.
19
+ #
20
+ # @param classes [Array<Class<Base>>]
19
21
  def initialize(classes = [])
20
22
  @classes = classes.dup
21
23
  end
22
24
 
23
- # @param klass [Class<Tracker>] replaces one registered under the same name
24
- # @return [Class<Tracker>]
25
+ # Later registration wins, which is how a suite swaps a built-in tracker.
26
+ #
27
+ # @param klass [Class<Base>] replaces one registered under the same name
28
+ # @return [Class<Base>] the class given
25
29
  def register(klass)
26
30
  @classes.reject! { it.tracker_name == klass.tracker_name }
27
31
  @classes << klass
28
32
  klass
29
33
  end
30
34
 
35
+ # Walks the classes in registration order.
36
+ #
37
+ # @yieldparam klass [Class<Base>] each tracker class
38
+ # @return [Array<Class<Base>>, Enumerator] the list; an Enumerator without a block
31
39
  def each(&) = @classes.each(&)
32
40
 
41
+ # For {#named}'s error message.
42
+ #
33
43
  # @return [Array<String>]
34
44
  def names = map(&:tracker_name)
35
45
 
36
- # @param name [String, Symbol]
37
- # @return [Class<Tracker>]
46
+ # Finds a tracker class by its {Base.tracker_name}.
47
+ #
48
+ # @param name [String, Symbol] the name as given
49
+ # @return [Class<Base>]
38
50
  # @raise [Error] naming the known trackers
39
51
  def named(name)
40
52
  find { it.tracker_name == name.to_s } || raise(Error, "no tracker named #{name.to_s.inspect}; known: #{names.join(", ")}")
@@ -47,9 +59,9 @@ module LLM
47
59
  #
48
60
  # @param flag [String, nil] what `--tracker` was given
49
61
  # @param config [String, nil] the config file's `tracker:`
50
- # @param env [Hash]
62
+ # @param env [#[]] the environment, ENV by default
51
63
  # @param local [Boolean] `--local`
52
- # @return [Array(Class<Tracker>, String), nil] the class and why it was chosen; nil for none
64
+ # @return [Array(Class<Base>, String), nil] the class and why it was chosen; nil for none
53
65
  # @raise [Error] on an unknown name or an ambiguous environment
54
66
  def choose(flag: nil, config: nil, env: ENV, local: false)
55
67
  return nil if local
@@ -69,8 +81,18 @@ module LLM
69
81
 
70
82
  private
71
83
 
84
+ # Whether a flag or config value actually says something; blank counts as unset.
85
+ #
86
+ # @param value [String, nil] the value given
87
+ # @return [Boolean]
72
88
  def present?(value) = !value.nil? && !value.to_s.strip.empty?
73
89
 
90
+ # Resolves a named choice, where the {NONE} spellings mean no remote tracker.
91
+ #
92
+ # @param name [String] the name as given
93
+ # @param from [String] where it came from, for `--verbose`
94
+ # @return [Array(Class<Base>, String), nil] the class and the reason; nil for none
95
+ # @raise [Error] on an unknown name
74
96
  def choice(name, from)
75
97
  return nil if NONE.include?(name.to_s)
76
98
 
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Where results go besides the screen: the local log and at most one remote service.
6
+ module Trackers
7
+ end
8
+ end
9
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  module LLM
4
4
  module Evals
5
- VERSION = "0.4.0"
5
+ # The gem's version, which `evals version` prints.
6
+ VERSION = "0.5.0"
7
+ RELEASED_ON = "2026-11-01"
6
8
  end
7
9
  end
data/lib/llm/evals.rb CHANGED
@@ -8,14 +8,16 @@ require "dry/cli/ui"
8
8
  require "dry/cli/autocomplete/command"
9
9
  require "zeitwerk"
10
10
 
11
+ # Home of the gem; everything it defines sits under {Evals}.
11
12
  module LLM
12
13
  # A generic evaluation runner with a one-way boundary. The consumer suite
13
- # registers an {Adapter}, which runs one {Case} against a {Model} and
14
- # returns a {Reply} without scoring it, and a {Scorer}, which turns a reply
15
- # into a {Score} without calling anything. Everything else, from discovery
14
+ # registers an {Actions::Adapter}, which runs one {Datasets::Case} against a {Models::Model} and
15
+ # returns a {Results::Reply} without scoring it, and a {Scoring::Scorer}, which turns a reply
16
+ # into a {Results::Score} without calling anything. Everything else, from discovery
16
17
  # to the leaderboard, is this gem's, and none of it knows what is being
17
18
  # evaluated.
18
19
  module Evals
20
+ # The base of every error the gem raises on purpose.
19
21
  class Error < StandardError; end
20
22
 
21
23
  # Raised by a command that needs an optional gem the bundle does not hold.
@@ -24,9 +26,15 @@ module LLM
24
26
  class << self
25
27
  # The suite's settings, filled in by {.configure} from `evals/eval_helper.rb`.
26
28
  #
27
- # @return [Configuration]
29
+ # @return [Runtime::Configuration]
28
30
  def configuration
29
- @configuration ||= Configuration.new
31
+ @configuration ||= Runtime::Configuration.new
32
+ end
33
+
34
+ # The root of the gem, which holds the default config and the CLI.
35
+ # @return [String]
36
+ def root
37
+ @root ||= File.expand_path("../..", __dir__)
30
38
  end
31
39
 
32
40
  # Builds the suite's configuration. Called once from the eval helper.
@@ -40,8 +48,8 @@ module LLM
40
48
  # c.before_run { MyGem.reset_configuration! }
41
49
  # end
42
50
  #
43
- # @yieldparam configuration [Configuration]
44
- # @return [Configuration]
51
+ # @yieldparam configuration [Runtime::Configuration]
52
+ # @return [Runtime::Configuration]
45
53
  def configure
46
54
  yield configuration
47
55
  configuration
@@ -54,6 +62,8 @@ module LLM
54
62
  @configuration = nil
55
63
  end
56
64
 
65
+ # A suite needs both halves of the boundary, so a helper that set only one does not count.
66
+ #
57
67
  # @return [Boolean] whether a helper has registered an adapter and a scorer
58
68
  def configured? = configuration.complete?
59
69