llm-evals 0.4.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 (65) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +26 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +764 -0
  5. data/exe/evals +9 -0
  6. data/exe/llm-evals +14 -0
  7. data/lib/llm/evals/adapter.rb +47 -0
  8. data/lib/llm/evals/case.rb +36 -0
  9. data/lib/llm/evals/cli/cases.rb +48 -0
  10. data/lib/llm/evals/cli/command.rb +163 -0
  11. data/lib/llm/evals/cli/compare.rb +40 -0
  12. data/lib/llm/evals/cli/formatting.rb +16 -0
  13. data/lib/llm/evals/cli/leaderboard.rb +183 -0
  14. data/lib/llm/evals/cli/model_colors.rb +23 -0
  15. data/lib/llm/evals/cli/model_command.rb +437 -0
  16. data/lib/llm/evals/cli/progress_bars.rb +147 -0
  17. data/lib/llm/evals/cli/render.rb +37 -0
  18. data/lib/llm/evals/cli/run.rb +69 -0
  19. data/lib/llm/evals/cli/standings.rb +114 -0
  20. data/lib/llm/evals/cli/verify.rb +24 -0
  21. data/lib/llm/evals/cli/version.rb +15 -0
  22. data/lib/llm/evals/cli.rb +43 -0
  23. data/lib/llm/evals/completion.rb +11 -0
  24. data/lib/llm/evals/config.rb +198 -0
  25. data/lib/llm/evals/configuration.rb +173 -0
  26. data/lib/llm/evals/contender.rb +31 -0
  27. data/lib/llm/evals/discovery.rb +95 -0
  28. data/lib/llm/evals/experiment.rb +158 -0
  29. data/lib/llm/evals/helper.rb +98 -0
  30. data/lib/llm/evals/launcher.rb +37 -0
  31. data/lib/llm/evals/metric.rb +26 -0
  32. data/lib/llm/evals/model.rb +55 -0
  33. data/lib/llm/evals/providers/base.rb +93 -0
  34. data/lib/llm/evals/providers/catalog.rb +32 -0
  35. data/lib/llm/evals/providers/open_router.rb +64 -0
  36. data/lib/llm/evals/providers/registry.rb +112 -0
  37. data/lib/llm/evals/providers/type_safe.rb +53 -0
  38. data/lib/llm/evals/render/reports/html.rb +266 -0
  39. data/lib/llm/evals/render/reports/json.rb +172 -0
  40. data/lib/llm/evals/render/reports/pipeline.rb +153 -0
  41. data/lib/llm/evals/render/reports/terminal.rb +46 -0
  42. data/lib/llm/evals/reply.rb +18 -0
  43. data/lib/llm/evals/run_result.rb +14 -0
  44. data/lib/llm/evals/runtime.rb +74 -0
  45. data/lib/llm/evals/schema_validator.rb +84 -0
  46. data/lib/llm/evals/score.rb +26 -0
  47. data/lib/llm/evals/scorer.rb +45 -0
  48. data/lib/llm/evals/templates/html_report.css +206 -0
  49. data/lib/llm/evals/templates/html_report.html.erb +184 -0
  50. data/lib/llm/evals/templates/html_report.js +48 -0
  51. data/lib/llm/evals/testing/echo_adapter.rb +29 -0
  52. data/lib/llm/evals/testing/fake_model.rb +18 -0
  53. data/lib/llm/evals/testing/fake_provider.rb +67 -0
  54. data/lib/llm/evals/tracker.rb +80 -0
  55. data/lib/llm/evals/trackers/braintrust.rb +91 -0
  56. data/lib/llm/evals/trackers/local.rb +78 -0
  57. data/lib/llm/evals/trackers/logfire.rb +143 -0
  58. data/lib/llm/evals/trackers/registry.rb +82 -0
  59. data/lib/llm/evals/usage.rb +33 -0
  60. data/lib/llm/evals/verify.rb +49 -0
  61. data/lib/llm/evals/version.rb +7 -0
  62. data/lib/llm/evals/warnings.rb +98 -0
  63. data/lib/llm/evals/weighted_score.rb +116 -0
  64. data/lib/llm/evals.rb +83 -0
  65. metadata +274 -0
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "tempfile"
5
+ require "uri"
6
+
7
+ module LLM
8
+ module Evals
9
+ module Render
10
+ module Reports
11
+ # Writes the canonical JSON snapshot and passes that snapshot to the
12
+ # selected display. Temporary snapshots are removed after presentation.
13
+ class Pipeline
14
+ Result = Data.define(:json_path, :html_path, :css_path, :js_path, :browser_opened)
15
+
16
+ # @param providers [Providers::Registry]
17
+ # @param runtime [Runtime]
18
+ # @param destination [String]
19
+ # @param temp_dir [String]
20
+ # @param project [String]
21
+ def initialize(providers:, runtime:, destination:, temp_dir:, project:)
22
+ @providers = providers
23
+ @runtime = runtime
24
+ @destination = destination
25
+ @temp_dir = temp_dir
26
+ @project = project.to_s.downcase.gsub(/[^a-z0-9]+/, "-").delete_prefix("-").delete_suffix("-")
27
+ end
28
+
29
+ # @param summaries [Array<Experiment::Summary>]
30
+ # @param metrics [Array<Metric>]
31
+ # @param weighted_score [WeightedScore, nil]
32
+ # @param run_options [Hash]
33
+ # @param autosave [Boolean]
34
+ # @param display [String]
35
+ # @param open [Boolean]
36
+ # @yield [report] display the decoded JSON in the terminal
37
+ # @return [Result]
38
+ def call(summaries:, metrics:, weighted_score:, run_options:, autosave:, display:, open:)
39
+ return Result.new(nil, nil, nil, nil, nil) unless autosave || display != "none"
40
+
41
+ stamp = timestamp
42
+ json_path = autosave ? artifact_path(@destination, stamp, "json") : temporary_json_path
43
+ write_json(summaries:, metrics:, weighted_score:, run_options:, path: json_path)
44
+ return Result.new(json_path:, html_path: nil, css_path: nil, js_path: nil, browser_opened: nil) if display == "none"
45
+
46
+ 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
57
+ Result.new(json_path: autosave ? json_path : nil, html_path:, css_path:, js_path:, browser_opened:)
58
+ ensure
59
+ FileUtils.rm_f(json_path) if defined?(json_path) && json_path && !autosave
60
+ end
61
+
62
+ # @param summaries [Array<Experiment::Summary>]
63
+ # @param metrics [Array<Metric>]
64
+ # @param weighted_score [WeightedScore, nil]
65
+ # @param run_options [Hash]
66
+ # @param path [String, nil] nil returns JSON text to the caller
67
+ # @return [String, nil] JSON text when no path was supplied
68
+ def write_json(summaries:, metrics:, weighted_score:, run_options:, path:)
69
+ document = JSON.dump(summaries, metrics:, weighted_score:, run_options:)
70
+ if path
71
+ FileUtils.mkdir_p(File.dirname(path))
72
+ File.write(path, document)
73
+ nil
74
+ else
75
+ document
76
+ end
77
+ end
78
+
79
+ # @param json [String] serialized run results
80
+ # @param path [String, nil] nil returns HTML text to the caller
81
+ # @return [String, nil] HTML text when no path was supplied
82
+ def write_html(json:, path:)
83
+ report = JSON.decode(json, providers: @providers)
84
+ document = HTML.render(report)
85
+ if path
86
+ FileUtils.mkdir_p(File.dirname(path))
87
+ File.write(path, document)
88
+ nil
89
+ else
90
+ document
91
+ end
92
+ end
93
+
94
+ # Writes an explicitly requested JSON report under the configured
95
+ # destination, using the same timestamped name as an autosaved result.
96
+ #
97
+ # @param summaries [Array<Experiment::Summary>]
98
+ # @param metrics [Array<Metric>]
99
+ # @param weighted_score [WeightedScore, nil]
100
+ # @param run_options [Hash]
101
+ # @return [String] the saved JSON path
102
+ def save_json(summaries:, metrics:, weighted_score:, run_options:)
103
+ path = artifact_path(@destination, timestamp, "json")
104
+ write_json(summaries:, metrics:, weighted_score:, run_options:, path:)
105
+ path
106
+ end
107
+
108
+ # The name of a project's report, the same on every run, so the newest
109
+ # replaces the last. The JSON beside it keeps the timestamp, and
110
+ # `render` can rebuild any older report from it.
111
+ #
112
+ # @param directory [String]
113
+ # @param extension [String] html, or the css and js that go with it
114
+ # @return [String]
115
+ def report_path(directory, extension = "html")
116
+ File.join(directory, "#{@project}-llm-evals.report.#{extension}")
117
+ end
118
+
119
+ private
120
+
121
+ def render_browser(report, open:)
122
+ FileUtils.mkdir_p(@destination)
123
+ html_path = report_path(@destination, "html")
124
+ css_path = report_path(@destination, "css")
125
+ js_path = report_path(@destination, "js")
126
+ File.write(css_path, File.read(File.join(HTML::TEMPLATE_DIRECTORY, "html_report.css")))
127
+ File.write(js_path, File.read(File.join(HTML::TEMPLATE_DIRECTORY, "html_report.js")))
128
+ html = HTML.render(report,
129
+ stylesheet_path: File.basename(css_path),
130
+ script_path: File.basename(js_path))
131
+ File.write(html_path, html)
132
+ browser_opened = @runtime.browse(URI::DEFAULT_PARSER.escape("file://#{html_path}")) if open
133
+ [html_path, css_path, js_path, browser_opened]
134
+ end
135
+
136
+ def artifact_path(directory, stamp, extension)
137
+ File.join(directory, "#{@project}.results.#{stamp}.#{extension}")
138
+ end
139
+
140
+ def timestamp = Time.now.strftime("%Y-%m-%d.%H%M%S%6N")
141
+
142
+ def temporary_json_path
143
+ FileUtils.mkdir_p(@temp_dir)
144
+ file = Tempfile.create(["#{@project}.results.", ".json"], @temp_dir)
145
+ path = file.path
146
+ file.close
147
+ path
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ module Render
6
+ module Reports
7
+ # Presents a decoded report in the terminal and optionally writes the
8
+ # detailed view to a report file.
9
+ class Terminal
10
+ # @param invocation [Dry::CLI::UI::Invocation]
11
+ # @param writing_to [#call] redirects the command's table helpers
12
+ # @param leaderboard [#call] draws the summary leaderboard
13
+ def initialize(invocation:, writing_to:, leaderboard:)
14
+ @invocation = invocation
15
+ @writing_to = writing_to
16
+ @leaderboard = leaderboard
17
+ end
18
+
19
+ # @param summaries [Array<Experiment::Summary>]
20
+ # @param output [String, nil]
21
+ # @param scores [Boolean]
22
+ # @param totals_mode [String] overall, variant or both
23
+ # @yield [summaries] draw the per-case detail
24
+ # @return [String, nil] report file path
25
+ def call(summaries:, output:, scores:, totals_mode:, &details)
26
+ path = @invocation.report_path(output)
27
+ if path
28
+ File.open(path, "w") do |file|
29
+ report = Dry::CLI::UI::Report.new(file)
30
+ @writing_to.call(report) do
31
+ details&.call(summaries)
32
+ @leaderboard.call(summaries, scores:, totals_mode:)
33
+ end
34
+ report.puts "Closed at #{Time.now.strftime("%Y-%m-%d %H:%M:%S %z")}"
35
+ end
36
+ else
37
+ details&.call(summaries)
38
+ end
39
+ @leaderboard.call(summaries, scores:, totals_mode:)
40
+ path
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # What an {Adapter} returns for one case. `error` is the model's failure
6
+ # (it did not answer the question: no JSON, a refusal) and scores 0; a
7
+ # transport failure is not the model's and is raised instead, so it is
8
+ # counted as an error of the run rather than a wrong answer.
9
+ Reply = Data.define(:output, :raw, :usage, :error, :metadata) do
10
+ def initialize(output: nil, raw: nil, usage: Usage.zero, error: nil, metadata: {})
11
+ super(output: output, raw: raw, usage: Usage.from(usage), error: error, metadata: metadata)
12
+ end
13
+
14
+ # @return [Boolean] whether the model failed to answer
15
+ def error? = !error.nil?
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # One case, run once: what came back and what it scored. `reply` is nil
6
+ # when the adapter raised, and `error` then holds the message.
7
+ RunResult = Data.define(:experiment, :case, :reply, :score, :duration, :error) do
8
+ def initialize(experiment:, case:, reply: nil, score: nil, duration: 0.0, error: nil) = super
9
+
10
+ # @return [Boolean] whether the adapter raised rather than answered
11
+ def failed? = !error.nil?
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dotenv"
4
+
5
+ module LLM
6
+ module Evals
7
+ # One-time process setup: secrets from the suite's env files, a check that
8
+ # every key and gem the run needs is there, the suite's own hooks, and
9
+ # the providers' clients.
10
+ class Runtime
11
+ attr_reader :env_files, :hooks, :app_name
12
+
13
+ # @param env_files [Array<String>] absolute paths; those that exist load
14
+ # @param hooks [Array<Proc>] the suite's `before_run` blocks
15
+ # @param app_name [String] what a provider tells its API the caller is
16
+ def initialize(env_files: [], hooks: [], app_name: "llm-evals")
17
+ @env_files = env_files
18
+ @hooks = hooks
19
+ @app_name = app_name
20
+ end
21
+
22
+ # Loads the env files that exist, without overriding what is already
23
+ # set, so an exported variable beats the file.
24
+ #
25
+ # @return [void]
26
+ def load_env
27
+ files = env_files.select { File.exist?(it) }
28
+ Dotenv.load(*files) unless files.empty?
29
+ end
30
+
31
+ # Fails before anything is called when a key the run needs is neither
32
+ # exported nor in an env file.
33
+ #
34
+ # @param names [Array<String>]
35
+ # @return [void]
36
+ # @raise [Error] naming every missing variable
37
+ def require_env!(names)
38
+ missing = names.uniq.select { ENV[it].to_s.strip.empty? }
39
+ return if missing.empty?
40
+
41
+ found = env_files.select { File.exist?(it) }
42
+ where = if found.empty?
43
+ env_files.empty? ? "no env files are configured" : "there is no #{env_files.join(" or ")}"
44
+ else
45
+ "checked the environment, then #{found.join(", ")}"
46
+ end
47
+ raise Error,
48
+ "missing #{missing.join(", ")}: export them or add them to an env file (#{where}); " \
49
+ "an exported blank value wins over the file"
50
+ end
51
+
52
+ # @param providers [Array<Providers::Base>] the providers the run's models use
53
+ # @param tracker [Class<Tracker>, nil] the remote tracker chosen, whose gems and key are checked too
54
+ # @return [void]
55
+ # @raise [Error] when a needed variable or gem is missing
56
+ def setup!(providers: [], tracker: nil)
57
+ load_env
58
+ require_env!(providers.flat_map(&:env_keys))
59
+ tracker&.requirements!(ENV)
60
+ hooks.each(&:call) # after load_env: a hook usually re-reads ENV
61
+ providers.each { it.setup!(app_name: app_name) }
62
+ end
63
+
64
+ # Opens a URL in the desktop browser.
65
+ #
66
+ # @param url [String]
67
+ # @return [Boolean] whether the opener ran
68
+ def browse(url)
69
+ opener = RUBY_PLATFORM.include?("darwin") ? "open" : "xdg-open"
70
+ system(opener, url, out: File::NULL, err: File::NULL) || false
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # Checks a Hash of answers against a field map, for scorers and verifiers
6
+ # whose expected values are typed and closed. Nothing here is specific to
7
+ # what the fields mean.
8
+ #
9
+ # The map is `{ "name" => { type:, enum:, nullable: } }`; `type` is one of
10
+ # {TYPES}, `enum` lists the allowed values for enum and multi_enum, and a
11
+ # nullable field accepts nil (the text never answered it).
12
+ class SchemaValidator
13
+ TYPES = %i[boolean integer number string enum multi_enum].freeze
14
+
15
+ attr_reader :fields
16
+
17
+ # @param fields [Hash{String, Symbol => Hash}]
18
+ # @raise [ArgumentError] on a type outside {TYPES}, since a typo there would pass everything
19
+ def initialize(fields)
20
+ @fields = fields.to_h { |name, spec| [name.to_s, normalize(name, spec)] }.freeze
21
+ end
22
+
23
+ # @return [Array<String>] the field names
24
+ def names = fields.keys
25
+
26
+ # @param values [Hash] String or Symbol keys
27
+ # @return [Array<String>] every problem found; empty when the values fit the map
28
+ def problems(values)
29
+ return ["#{values.inspect} is not a Hash"] unless values.is_a?(Hash)
30
+
31
+ actual = values.transform_keys(&:to_s)
32
+ missing = names - actual.keys
33
+ extra = actual.keys - names
34
+ problems = []
35
+ problems << "missing #{missing.join(", ")}" if missing.any?
36
+ problems << "unknown fields #{extra.join(", ")}" if extra.any?
37
+ fields.each do |name, spec|
38
+ next unless actual.key?(name)
39
+
40
+ problem = value_problem(spec, actual[name])
41
+ problems << "#{name}: #{problem}" if problem
42
+ end
43
+ problems
44
+ end
45
+
46
+ # @param values [Hash]
47
+ # @return [Boolean]
48
+ def valid?(values) = problems(values).empty?
49
+
50
+ # @param spec [Hash] one field's normalized spec
51
+ # @param value [Object]
52
+ # @return [String, nil] what is wrong with the value, or nil
53
+ def value_problem(spec, value)
54
+ return (spec[:nullable] ? nil : "is null") if value.nil?
55
+
56
+ case spec[:type]
57
+ when :boolean then "#{value.inspect} is not a boolean" unless [true, false].include?(value)
58
+ when :integer then "#{value.inspect} is not an integer" unless value.is_a?(Integer)
59
+ when :number then "#{value.inspect} is not a number" unless value.is_a?(Numeric)
60
+ when :string then "#{value.inspect} is not a string" unless value.is_a?(String)
61
+ when :enum then "#{value.inspect} is not one of #{spec[:enum].join("|")}" unless spec[:enum].include?(value)
62
+ when :multi_enum then multi_enum_problem(spec, value)
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ def multi_enum_problem(spec, value)
69
+ return "#{value.inspect} is not an array" unless value.is_a?(Array)
70
+
71
+ stray = value - spec[:enum]
72
+ "#{stray.join(", ")} not in #{spec[:enum].join("|")}" if stray.any?
73
+ end
74
+
75
+ def normalize(name, spec)
76
+ spec = spec.transform_keys(&:to_sym)
77
+ type = spec.fetch(:type).to_sym
78
+ raise ArgumentError, "#{name}: unknown type #{type.inspect}, expected one of #{TYPES.join(", ")}" unless TYPES.include?(type)
79
+
80
+ { type: type, enum: Array(spec[:enum] || spec[:values]).map(&:to_s), nullable: spec.fetch(:nullable, false) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # What a {Scorer} says about one reply. `value` is the case's overall
6
+ # score in 0..1; `metrics` holds one entry per {Metric} the scorer
7
+ # declares, nil where the case does not apply; `outcomes` and
8
+ # `diagnostics` are free-form, keyed by field, and only ever displayed.
9
+ Score = Data.define(:value, :metrics, :outcomes, :diagnostics) do
10
+ def initialize(value:, metrics: {}, outcomes: {}, diagnostics: {})
11
+ raise ArgumentError, "score value #{value.inspect} is not within 0..1" unless value.is_a?(Numeric) && (0..1).cover?(value)
12
+
13
+ super(value: value.to_f, metrics: metrics.transform_keys(&:to_s), outcomes: outcomes, diagnostics: diagnostics)
14
+ end
15
+
16
+ # The score of a reply the model failed: 0 everywhere, the failure as a diagnostic.
17
+ #
18
+ # @param metrics [Array<Metric>]
19
+ # @param error [String]
20
+ # @return [Score]
21
+ def self.failed(metrics, error)
22
+ new(value: 0.0, metrics: metrics.to_h { [it.name, 0.0] }, diagnostics: { "error" => error })
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LLM
4
+ module Evals
5
+ # What a suite implements to score one reply. Pure and deterministic: it
6
+ # reads the case, the reply and the adapter's context, and calls nothing.
7
+ #
8
+ # @abstract Subclass and override {#metrics} and {#call}.
9
+ class Scorer
10
+ # The metrics every {Score} this scorer returns carries. The CLI builds
11
+ # its columns, its glossary and its Braintrust scorers from this list,
12
+ # so a metric missing here is a metric nobody sees.
13
+ #
14
+ # @return [Array<Metric>]
15
+ def metrics
16
+ raise NotImplementedError, "#{self.class}#metrics must return an Array of LLM::Evals::Metric"
17
+ end
18
+
19
+ # @return [Metric] the one flagged primary, else the first
20
+ def primary = metrics.find(&:primary) || metrics.first
21
+
22
+ # @param kase [Case]
23
+ # @param reply [Reply] never one with `error` set; the runner scores those 0 itself
24
+ # @param context [Hash] what {Adapter#context} returned for the case
25
+ # @return [Score]
26
+ def call(kase, reply, context:)
27
+ raise NotImplementedError, "#{self.class}#call(kase, reply, context:) must return an LLM::Evals::Score"
28
+ end
29
+
30
+ # Offline checks of one case against what the scorer will demand of it,
31
+ # so a typo in expected.json is caught before it is billed as a model's mistake.
32
+ #
33
+ # @param kase [Case]
34
+ # @param context [Hash] what {Adapter#context} returned for the case
35
+ # @return [Array<String>] problems; empty when the case is sound
36
+ def verify(kase, context:) = [] # rubocop:disable Lint/UnusedMethodArgument -- the signature is the contract
37
+
38
+ # One short cell describing the case in `llm-evals cases`, e.g. "5/8 answered".
39
+ #
40
+ # @param kase [Case]
41
+ # @return [String, nil] nil for the generic fallback
42
+ def summary(_kase) = nil
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,206 @@
1
+ :root {
2
+ color-scheme: dark;
3
+ --bg: #0b1014;
4
+ --panel: #121a20;
5
+ --panel-raised: #19232b;
6
+ --text: #e8eef1;
7
+ --muted: #93a3ad;
8
+ --line: #2a3942;
9
+ --blue: #73bfff;
10
+ --green: #55d79a;
11
+ --orange: #ffad5c;
12
+ --red: #ff746d;
13
+ }
14
+
15
+ * { box-sizing: border-box; }
16
+
17
+ html { scroll-behavior: smooth; scroll-padding-top: 88px; }
18
+
19
+ body {
20
+ margin: 0;
21
+ background: var(--bg);
22
+ color: var(--text);
23
+ font: 15px/1.55 Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
24
+ }
25
+
26
+ main {
27
+ max-width: 1600px;
28
+ margin: auto;
29
+ padding: 36px 32px 84px;
30
+ }
31
+
32
+ .report-header {
33
+ display: flex;
34
+ align-items: flex-end;
35
+ justify-content: space-between;
36
+ gap: 24px;
37
+ margin-bottom: 22px;
38
+ scroll-margin-top: 90px;
39
+ }
40
+
41
+ .report-header .eyebrow { margin: 0 0 6px; color: var(--green); }
42
+ h1 { margin: 0; font-size: clamp(28px, 4vw, 42px); font-weight: 650; letter-spacing: -.035em; line-height: 1.12; }
43
+ h2 { margin: 0 0 8px; font-size: 21px; font-weight: 620; letter-spacing: -.02em; }
44
+ h3 { margin: 25px 0 10px; font-size: 15px; font-weight: 650; }
45
+ h4 { margin: 14px 0 6px; }
46
+ p { max-width: 78ch; }
47
+ .muted { color: var(--muted); }
48
+ .report-header .muted { margin: 9px 0 0; }
49
+
50
+ .top-link {
51
+ flex: none;
52
+ padding: 8px 12px;
53
+ border: 1px solid var(--line);
54
+ border-radius: 7px;
55
+ color: var(--muted);
56
+ text-decoration: none;
57
+ }
58
+ .top-link:hover, .top-link:focus-visible { border-color: var(--green); color: var(--text); }
59
+
60
+ .section-nav {
61
+ position: sticky;
62
+ z-index: 5;
63
+ top: 0;
64
+ display: flex;
65
+ gap: 5px;
66
+ margin: 0 -12px 22px;
67
+ padding: 9px 12px;
68
+ overflow-x: auto;
69
+ border: 1px solid var(--line);
70
+ border-radius: 9px;
71
+ background: rgb(11 16 20 / 94%);
72
+ box-shadow: 0 8px 24px rgb(0 0 0 / 22%);
73
+ backdrop-filter: blur(12px);
74
+ scrollbar-width: thin;
75
+ }
76
+
77
+ .section-nav a {
78
+ flex: none;
79
+ padding: 7px 11px;
80
+ border-radius: 6px;
81
+ color: var(--muted);
82
+ font-size: 13px;
83
+ text-decoration: none;
84
+ transition: color .15s ease, background-color .15s ease;
85
+ }
86
+ .section-nav a:hover, .section-nav a:focus-visible { background: var(--panel-raised); color: var(--text); outline: none; }
87
+ .section-nav a:focus-visible { box-shadow: 0 0 0 2px var(--blue); }
88
+
89
+ section,
90
+ .dataset {
91
+ margin: 16px 0;
92
+ padding: 20px;
93
+ border: 1px solid var(--line);
94
+ border-radius: 11px;
95
+ background: var(--panel);
96
+ scroll-margin-top: 82px;
97
+ }
98
+
99
+ .health {
100
+ display: grid;
101
+ grid-template-columns: repeat(auto-fit, minmax(155px, 1fr));
102
+ gap: 10px;
103
+ padding: 0;
104
+ border: 0;
105
+ background: none;
106
+ }
107
+
108
+ .stat {
109
+ min-height: 94px;
110
+ padding: 14px 16px;
111
+ border: 1px solid var(--line);
112
+ border-radius: 9px;
113
+ background: var(--panel);
114
+ }
115
+ .stat b { display: block; font-size: 24px; font-variant-numeric: tabular-nums; letter-spacing: -.025em; }
116
+ .stat span, .eyebrow { color: var(--muted); }
117
+ .stat span { font-size: 12px; }
118
+
119
+ .notice { padding: 12px 14px; border-left: 3px solid var(--red); background: #ff746d12; }
120
+ .leader {
121
+ display: grid;
122
+ gap: 5px;
123
+ margin: 18px 0;
124
+ padding: 17px 18px;
125
+ border: 1px solid #55d79a80;
126
+ border-radius: 9px;
127
+ background: #55d79a0d;
128
+ }
129
+ .leader.none { border-color: #ff746d80; background: #ff746d0d; }
130
+ .leader strong { font-size: 21px; }
131
+ .eyebrow { font-size: 11px; letter-spacing: .075em; }
132
+ .provider { color: var(--green); font-weight: 650; }
133
+ .model-id { color: var(--orange); font-weight: 700; }
134
+ .settings { display: block; color: var(--muted); font-size: 12px; }
135
+
136
+ .track { height: 12px; overflow: hidden; border-radius: 99px; background: var(--line); }
137
+ .track i { display: block; height: 100%; background: linear-gradient(90deg, var(--blue), var(--green)); }
138
+
139
+ table { width: 100%; min-width: 980px; border-collapse: collapse; font-size: 13px; }
140
+ th, td { padding: 10px 11px; border-top: 1px solid var(--line); text-align: left; vertical-align: top; }
141
+ th { color: var(--muted); font-size: 12px; font-weight: 550; white-space: nowrap; }
142
+ tbody tr:hover { background: rgb(255 255 255 / 2.5%); }
143
+ td { font-variant-numeric: tabular-nums; }
144
+ .sortable th { cursor: pointer; user-select: none; }
145
+ .sortable th::after { color: var(--muted); font-size: 11px; content: " ↕"; }
146
+ .sortable th[aria-sort="ascending"]::after { content: " ↑"; }
147
+ .sortable th[aria-sort="descending"]::after { content: " ↓"; }
148
+ .sortable th:focus-visible { outline: 2px solid var(--blue); outline-offset: -2px; }
149
+ .standings-table th > span { display: block; }
150
+ .standings-settings { color: var(--text); font-weight: 400; }
151
+ .standings-table .standings-bar { min-width: 300px; }
152
+
153
+ .scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-inline: contain; -webkit-overflow-scrolling: touch; }
154
+ th:first-child, td:first-child { white-space: nowrap; }
155
+ .dataset { overflow: hidden; padding: 0; }
156
+ .dataset[open] { border-color: #3b505a; }
157
+ .dataset > summary, .case > summary {
158
+ display: flex;
159
+ justify-content: space-between;
160
+ gap: 16px;
161
+ padding: 15px 18px;
162
+ list-style: none;
163
+ cursor: pointer;
164
+ }
165
+ .dataset > summary { align-items: center; background: var(--panel-raised); }
166
+ .dataset > summary::before, .case > summary::before { content: "+"; color: var(--green); font-size: 13px; }
167
+ .dataset[open] > summary::before, .case[open] > summary::before { content: "−"; }
168
+ .dataset > summary::-webkit-details-marker, .case > summary::-webkit-details-marker { display: none; }
169
+ .dataset > summary strong { margin-right: auto; font-size: 16px; }
170
+ .dataset > summary span, .case > summary span { color: var(--muted); }
171
+ .dataset-content { padding: 0 18px 20px; }
172
+ .dataset-content > h3:first-child { margin-top: 20px; }
173
+
174
+ .case { overflow: hidden; margin: 8px 0; border: 1px solid var(--line); border-radius: 8px; }
175
+ .case > summary { align-items: center; padding: 10px 12px; }
176
+ .case-content { padding: 0 12px 12px; }
177
+ .case-content pre { max-height: 360px; overflow: auto; padding: 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); white-space: pre-wrap; overflow-wrap: anywhere; }
178
+ pre { font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, monospace; }
179
+ .case-content details { margin: 8px 0; }
180
+ .case-content details > summary { color: var(--blue); cursor: pointer; }
181
+
182
+ @media (prefers-reduced-motion: reduce) {
183
+ html { scroll-behavior: auto; }
184
+ *, *::before, *::after { transition-duration: .01ms !important; }
185
+ }
186
+
187
+ @media (max-width: 900px) {
188
+ main { padding: 26px 20px 60px; }
189
+ }
190
+
191
+ @media (max-width: 700px) {
192
+ main { padding: 20px 12px 42px; }
193
+ .report-header { align-items: flex-start; }
194
+ .top-link { display: none; }
195
+ .section-nav { margin-right: -4px; margin-left: -4px; }
196
+ section { padding: 15px; }
197
+ .dataset-content { padding: 0 10px 12px; }
198
+ .health { grid-template-columns: repeat(2, minmax(0, 1fr)); }
199
+ .stat { min-height: 80px; padding: 11px; }
200
+ .stat b { font-size: 20px; }
201
+ .dataset > summary { align-items: flex-start; flex-wrap: wrap; }
202
+ .dataset > summary span { flex-basis: 100%; padding-left: 22px; }
203
+ .case > summary { align-items: flex-start; flex-wrap: wrap; }
204
+ .case > summary span { flex-basis: 100%; padding-left: 22px; }
205
+ table { min-width: 900px; }
206
+ }