rubric_llm 0.4.0 → 0.6.0.rc1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16ccb30778e4ace0b35900e0d488ac884042acf124e139181e4e364a6a5829ed
4
- data.tar.gz: f5c1df6b47de48bd4c976a9986836aa2d2c66c6614dc94e4b6f4f8b12c090144
3
+ metadata.gz: 6d32ed4cba47a2fac15973d05bbf531317fa8d80f4c42b277dae5c302884ae57
4
+ data.tar.gz: a4bfbbc318593df77c33d36d41eda2e3d2f1b0cd4d0485fe9b8a61e72ee1519b
5
5
  SHA512:
6
- metadata.gz: d267c7eceba64ebf8b9f5829b960ebf45aaec98bbd8545a393f16f8dfdf0070fb98ee8038e0fe9ddb1428164bbbcb677b6e8da0afa86513865187a358aad45a7
7
- data.tar.gz: d0fff2333050e27bb785820f128ebaee7161f15e446f728beffbe2744078d95cd0de077b4a2674b26d8136391ee9c977f5cc87a1608f48ff96d9e0ed8cd04dd8
6
+ metadata.gz: a63b324d6f721297415feb55f6232b2771d58a7ca86a4a2ff0903abffec3c5fc5ad638c3d32632ed27bbf6cafea666a8247dcfa418d3454df61e3856c6c83b0a
7
+ data.tar.gz: 424912623b5f7f8157c91c10310c4e8ebe34e7406fcc146a3c146bd4e9de0cbe9b0100acc4b7a9a98174153f53178961f9c8233b120b3d3d23c11a5bf29bb360
data/CHANGELOG.md CHANGED
@@ -5,6 +5,45 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0.rc1] - 2026-09-08
9
+
10
+ This prerelease targets RubyLLM `2.0.0.rc1` and supports RubyLLM 2 only. Applications that use RubyLLM 1.x should stay on RubricLLM `0.5.x`. Stable `0.6.0` waits for the final RubyLLM 2.0 release and the complete Ruby 3.4 and 4.0 test matrix.
11
+
12
+ ### Changed
13
+
14
+ - Require the exact RubyLLM `2.0.0.rc1` prerelease while the compatibility release is under review; Ruby >= 3.4 remains supported
15
+ - Keep `max_tokens` and `RUBRIC_MAX_TOKENS` as the public RubricLLM setting while RubyLLM maps the limit to the selected provider and protocol
16
+ - Treat explicit `temperature: nil` as an instruction to omit temperature from the provider request. Before 0.6.0.rc1, it selected `RUBRIC_TEMPERATURE` or `0.0`; an omitted temperature still reads `RUBRIC_TEMPERATURE` and defaults to `0.0`
17
+ - Use OpenAI Responses by default through RubyLLM 2. OpenAI-compatible gateways that only accept Chat Completions can set `config.openai_protocol = :chat_completions`
18
+ - Keep RubyLLM transport retries and RubricLLM judge retries separate. With RubyLLM's default `config.max_retries = 3` and RubricLLM's default `max_retries: 2`, a retryable metric failure can produce up to `(3 + 1) * (2 + 1) = 12` HTTP attempts
19
+ - Keep Rails persistence migrations separate. Applications that use RubyLLM's Rails records must follow RubyLLM's upstream 2.0 migration guide
20
+
21
+ ## [0.5.0] - 2026-08-23
22
+
23
+ ### Fixed
24
+
25
+ - The RSpec `hallucinate` matcher no longer reports a hallucination when the judge returns no score. A failed judge call previously read as a positive match and failed the build with a false verdict
26
+ - `Comparison` pairs samples by `sample[:question]` instead of array position. Two reports built from the same dataset in a different order produced an invalid paired t-test. Questions present in only one report are dropped with a warning
27
+ - `Judge#call` retries only transient failures (`RubyLLM::RateLimitError`, `ServerError`, `ServiceUnavailableError`, `OverloadedError`). A bad API key, an exhausted quota, an over-long prompt, or a judge contract violation now fails on the first attempt instead of sleeping through the full retry schedule. Transport timeouts and connection resets are left to RubyLLM's connection, which already retries them
28
+ - `ContextPrecision` and `ContextRecall` drop blank and whitespace-only context chunks and return a `No context provided` error when nothing usable remains, matching `Faithfulness`
29
+ - `Statistics.two_tailed_p` rescues only `Math::DomainError`, `ZeroDivisionError`, and `FloatDomainError`. It previously swallowed every `StandardError` and returned 1.0
30
+ - A paired t-test over a constant difference returns 1.0 instead of a spurious near-zero p-value caused by float error in the variance
31
+ - The RSpec `hallucinate` matcher renders a missing score as `nil` in its failure message instead of an empty string
32
+
33
+ ### Removed
34
+
35
+ - The `Comparison` constructor no longer warns about reports of different sizes. Different sizes are fine when the questions match, and equal sizes can still drop every pair when they do not. The pairing warnings report what is actually dropped
36
+
37
+ ### Added
38
+
39
+ - Holm-Bonferroni correction across metrics in `Comparison`. Each result carries `:p_value_adjusted` next to the raw `:p_value`, `#summary` prints a `p-adj` column, and `#significant_improvements` / `#significant_regressions` test the adjusted value. Six independent tests at alpha 0.05 gave a family-wise false-positive rate near 26%
40
+ - `RubricLLM::Statistics`, a module holding the paired t-test, the two-tailed p-value, the Holm adjustment, and the incomplete beta function. No LLM calls, no state
41
+ - `Metrics::Base.normalize_context`, the single definition of usable context
42
+
43
+ ### Changed
44
+
45
+ - The Minitest `assert_faithful` and `refute_hallucination` helpers and the RSpec `be_faithful` and `hallucinate` matchers raise `ArgumentError` on an empty or blank context instead of returning a verdict that no judge produced
46
+
8
47
  ## [0.4.0] - 2026-07-11
9
48
 
10
49
  ### Added
data/README.md CHANGED
@@ -6,20 +6,31 @@ Lightweight LLM evaluation framework for Ruby, inspired by [DeepEval](https://gi
6
6
  [![CI](https://github.com/dpaluy/rubric_llm/actions/workflows/ci.yml/badge.svg)](https://github.com/dpaluy/rubric_llm/actions/workflows/ci.yml)
7
7
  [![Wiki](https://img.shields.io/badge/docs-wiki-blue)](https://github.com/dpaluy/rubric_llm/wiki)
8
8
 
9
- Provider-agnostic evaluation with pluggable metrics, statistical A/B comparison, and test framework integration no Rails, no ActiveRecord, no UI. Works anywhere Ruby runs.
9
+ Provider-agnostic evaluation with pluggable metrics, statistical A/B comparison, and test framework integration: no Rails, no ActiveRecord, no UI. Works anywhere Ruby runs.
10
+
11
+ `0.6.0.rc1` is a prerelease for RubyLLM `2.0.0.rc1`. It supports RubyLLM 2 only. Applications that use RubyLLM 1.x should stay on RubricLLM `0.5.x`. Stable `0.6.0` waits for the final RubyLLM 2.0 release and the complete Ruby 3.4 and 4.0 test matrix.
10
12
 
11
13
  ## Installation
12
14
 
13
- Add to your Gemfile:
15
+ Install the release candidate with both exact prerelease constraints in the same Gemfile change:
14
16
 
15
17
  ```ruby
16
- gem "rubric_llm"
18
+ gem "rubric_llm", "0.6.0.rc1"
19
+ gem "ruby_llm", "2.0.0.rc1"
17
20
  ```
18
21
 
22
+ Then resolve both gems together:
23
+
24
+ ```bash
25
+ bundle update rubric_llm ruby_llm
26
+ ```
27
+
28
+ RubyLLM's upstream prerelease pin command is `bundle add ruby_llm --version 2.0.0.rc1`. If the application still has a RubyLLM 1.x constraint, update both constraints before running Bundler.
29
+
19
30
  Or install directly:
20
31
 
21
32
  ```bash
22
- gem install rubric_llm
33
+ gem install rubric_llm --version 0.6.0.rc1 --pre
23
34
  ```
24
35
 
25
36
  ## Quick Start
@@ -62,6 +73,25 @@ RubricLLM.configure do |c|
62
73
  end
63
74
  ```
64
75
 
76
+ `max_tokens` remains RubricLLM's public setting, and `RUBRIC_MAX_TOKENS` remains its environment variable. RubyLLM 2 maps this shared limit to the selected provider and protocol.
77
+
78
+ When `temperature` is omitted, RubricLLM reads `RUBRIC_TEMPERATURE` and uses `0.0` when the variable is not set. Before 0.6.0.rc1, an explicit `temperature: nil` selected that environment value or `0.0`; it now means that RubricLLM omits temperature from the provider request, so the provider chooses its default:
79
+
80
+ ```ruby
81
+ RubricLLM::Config.new # RUBRIC_TEMPERATURE, otherwise 0.0
82
+ RubricLLM::Config.new(temperature: nil) # omit temperature from the request
83
+ ```
84
+
85
+ RubyLLM 2 uses the OpenAI Responses protocol by default when the selected model supports it. For an OpenAI-compatible gateway that only accepts Chat Completions, configure RubyLLM before evaluating:
86
+
87
+ ```ruby
88
+ RubyLLM.configure do |config|
89
+ config.openai_protocol = :chat_completions
90
+ end
91
+ ```
92
+
93
+ Structured output support depends on the selected provider and model. RubricLLM sends its schema when RubyLLM reports structured output support. Otherwise it requests JSON text and validates the response object and score locally. Check the target provider's support before relying on a schema or a specific protocol. See RubyLLM's [2.0 upgrade guide](https://rubyllm.com/next/upgrading/), [request control guide](https://rubyllm.com/next/chat-request-control/), and [structured output support](https://rubyllm.com/next/structured-output/).
94
+
65
95
  ### Environment Variables
66
96
 
67
97
  All config fields can be set via environment variables:
@@ -100,6 +130,14 @@ RubricLLM.configure do |c|
100
130
  end
101
131
  ```
102
132
 
133
+ RubricLLM has no Rails models or database migrations. If the application also uses RubyLLM's Rails persistence, follow RubyLLM's 2.0 upgrade guide and run its phased migrations separately.
134
+
135
+ ### Retries
136
+
137
+ RubyLLM transport retries and RubricLLM judge retries remain separate. With RubyLLM's default `config.max_retries = 3` and RubricLLM's default `max_retries: 2`, one retryable metric failure can produce up to `(3 + 1) * (2 + 1) = 12` HTTP attempts. Set `RUBRIC_MAX_RETRIES` and `RUBRIC_RETRY_BASE_DELAY` for RubricLLM's layer, and set RubyLLM's `config.max_retries` and related transport settings for its layer. RubricLLM 0.6 does not combine or redesign these retry layers.
138
+
139
+ RubyLLM classifies OpenAI's HTTP 429 `insufficient_quota` response as a rate-limit error, so an exhausted account uses both retry budgets and their delays before the error is returned.
140
+
103
141
  ## Metrics
104
142
 
105
143
  ### LLM-as-Judge Metrics
@@ -116,14 +154,14 @@ These metrics use a judge LLM to evaluate quality. Each sends a structured promp
116
154
  | **Faithfulness** | Is every claim in the answer supported by the context? | `context` |
117
155
 
118
156
  ```ruby
119
- # Only context gets faithfulness, relevance, context_precision
157
+ # Only context (gets faithfulness, relevance, context_precision)
120
158
  result = RubricLLM.evaluate(
121
159
  question: "How does photosynthesis work?",
122
160
  answer: "Plants convert sunlight into energy.",
123
161
  context: ["Photosynthesis is the process by which plants convert light energy into chemical energy."]
124
162
  )
125
163
 
126
- # With ground truth gets all metrics
164
+ # With ground truth (gets all metrics)
127
165
  result = RubricLLM.evaluate(
128
166
  question: "How does photosynthesis work?",
129
167
  answer: "Plants convert sunlight into energy.",
@@ -155,7 +193,7 @@ result.scores[:tone_metric] # => 0.85
155
193
 
156
194
  ### Retrieval Metrics
157
195
 
158
- Pure math no LLM calls, no API key needed.
196
+ Pure math, no LLM calls, no API key needed.
159
197
 
160
198
  ```ruby
161
199
  result = RubricLLM.evaluate_retrieval(
@@ -164,9 +202,9 @@ result = RubricLLM.evaluate_retrieval(
164
202
  )
165
203
 
166
204
  result.precision_at_k(3) # => 0.67
167
- result.recall_at_k(3) # => 0.90
168
- result.mrr # => 0.90
169
- result.ndcg # => 0.86
205
+ result.recall_at_k(3) # => 1.0
206
+ result.mrr # => 1.0
207
+ result.ndcg # => 0.92
170
208
  result.hit_rate # => 1.0
171
209
  ```
172
210
 
@@ -217,12 +255,14 @@ comparison = RubricLLM.compare(report_a, report_b)
217
255
 
218
256
  puts comparison.summary
219
257
  # A/B Comparison
220
- # ======================================================================
221
- # Metric A B Delta p-value Sig
222
- # ----------------------------------------------------------------------
223
- # faithfulness 0.880 0.920 +0.040 0.0230 *
224
- # relevance 0.850 0.860 +0.010 0.4210
225
- # correctness 0.910 0.940 +0.030 0.0089 **
258
+ # ================================================================================
259
+ # Metric A B Delta p-value p-adj Sig
260
+ # --------------------------------------------------------------------------------
261
+ # faithfulness 0.880 0.920 +0.040 0.0023 0.0068 **
262
+ # relevance 0.850 0.860 +0.010 0.3081 0.3081
263
+ # correctness 0.910 0.940 +0.030 0.0240 0.0480 *
264
+ #
265
+ # p-adj: Holm-Bonferroni adjusted across 3 metrics. Significance uses p-adj.
226
266
 
227
267
  comparison.significant_improvements # => [:faithfulness, :correctness]
228
268
  comparison.significant_regressions # => []
@@ -230,6 +270,16 @@ comparison.significant_regressions # => []
230
270
 
231
271
  Significance markers: `*` (p < 0.05), `**` (p < 0.01), `***` (p < 0.001)
232
272
 
273
+ ### Pairing
274
+
275
+ A paired t-test needs the same sample on both sides. The comparison pairs results by `sample[:question]`, not by position, so a reordered dataset still gives a valid test. Questions present in only one report are dropped with a warning. If a question repeats an uneven number of times across the two reports, the extra occurrences are dropped with a warning.
276
+
277
+ `evaluate_batch` requires a `:question` on every sample, so reports it produces always pair by identity. Hand-built reports whose results carry no `sample[:question]` fall back to position pairing and warn.
278
+
279
+ ### Multiple comparisons
280
+
281
+ Every metric gets its own t-test. Six tests at alpha 0.05 give a family-wise false-positive rate near 26%, so each raw `p_value` is corrected with the Holm-Bonferroni step-down method and reported as `p_value_adjusted`. The significance markers and both `significant_*` methods read the adjusted value. The raw value stays in the result for reference.
282
+
233
283
  For the statistical reasoning behind paired t-tests and how to read these p-values, see [Understanding A/B Comparison](https://github.com/dpaluy/rubric_llm/wiki/Understanding-A-B-Comparison) on the wiki.
234
284
 
235
285
  ## Test Integration
@@ -298,7 +348,7 @@ rescue RubricLLM::Error => e
298
348
  end
299
349
  ```
300
350
 
301
- Individual metric failures are handled gracefully a failed metric returns `nil` for the score and includes the error in details:
351
+ Individual metric failures are handled gracefully: a failed metric returns `nil` for the score and includes the error in details:
302
352
 
303
353
  ```ruby
304
354
  result = RubricLLM.evaluate(question: "q", answer: "a")
@@ -311,18 +361,18 @@ result.overall # => mean of non-nil scores only
311
361
 
312
362
  ```bash
313
363
  bundle install
314
- bundle exec rake test
364
+ bundle exec rake test test_contract
315
365
  bundle exec rubocop
316
366
  ```
317
367
 
318
368
  ## Limitations
319
369
 
320
- RubricLLM uses LLM-as-Judge an LLM scores another LLM's output. This is the industry-standard approach (used by Ragas, DeepEval, ARES), but it means the judge shares the same class of failure modes as the system being evaluated. If the judge hallucinates that an answer is faithful, you get a false positive.
370
+ RubricLLM uses LLM-as-Judge: an LLM scores another LLM's output. This is the industry-standard approach (used by Ragas, DeepEval, ARES), but it means the judge shares the same class of failure modes as the system being evaluated. If the judge hallucinates that an answer is faithful, you get a false positive.
321
371
 
322
372
  Mitigations built into the framework:
323
373
 
324
374
  - **Cross-model judging.** Configure a different model as judge than the one being evaluated. Don't let gpt-5.5 grade gpt-5.5.
325
- - **Retrieval metrics are pure math.** `precision_at_k`, `recall_at_k`, `mrr`, `ndcg` no LLM involved, no judge bias. See [Why Retrieval Metrics Are Pure Math](https://github.com/dpaluy/rubric_llm/wiki/Why-Retrieval-Metrics-Are-Pure-Math).
375
+ - **Retrieval metrics are pure math.** `precision_at_k`, `recall_at_k`, `mrr`, `ndcg` (no LLM involved, no judge bias). See [Why Retrieval Metrics Are Pure Math](https://github.com/dpaluy/rubric_llm/wiki/Why-Retrieval-Metrics-Are-Pure-Math).
326
376
  - **Custom non-LLM metrics.** Subclass `Metrics::Base` with regex checks, embedding similarity, or any deterministic logic.
327
377
  - **Statistical comparison.** A/B testing with paired t-tests surfaces systematic judge bias across runs.
328
378
 
@@ -338,7 +388,7 @@ Ruby has two LLM evaluation options today. Neither fits most use cases:
338
388
  | **LLM access** | Raw HTTP (OpenAI/Anthropic only) | You implement it | RubyLLM (any provider) |
339
389
  | **Rails required?** | No | Yes (engine + 6 migrations) | No |
340
390
  | **ActiveRecord?** | No | Yes | No |
341
- | **A/B comparison** | Basic | No | Paired t-test with p-values |
391
+ | **A/B comparison** | Basic | No | Paired t-test with Holm-corrected p-values |
342
392
  | **Test assertions** | Minitest + RSpec | No | Minitest + RSpec |
343
393
  | **Pluggable metrics** | No (fixed set) | Yes | Yes |
344
394
  | **Retrieval metrics** | Yes | No | Yes |
@@ -347,13 +397,13 @@ Ruby has two LLM evaluation options today. Neither fits most use cases:
347
397
 
348
398
  Deep dives live in the [project wiki](https://github.com/dpaluy/rubric_llm/wiki):
349
399
 
350
- - [Understanding A/B Comparison](https://github.com/dpaluy/rubric_llm/wiki/Understanding-A-B-Comparison) what paired t-tests and p-values mean for model comparison
351
- - [Why Retrieval Metrics Are Pure Math](https://github.com/dpaluy/rubric_llm/wiki/Why-Retrieval-Metrics-Are-Pure-Math) why `precision_at_k`, `recall_at_k`, `mrr`, `ndcg`, and `hit_rate` are deterministic and bias-free
400
+ - [Understanding A/B Comparison](https://github.com/dpaluy/rubric_llm/wiki/Understanding-A-B-Comparison): what paired t-tests and p-values mean for model comparison
401
+ - [Why Retrieval Metrics Are Pure Math](https://github.com/dpaluy/rubric_llm/wiki/Why-Retrieval-Metrics-Are-Pure-Math): why `precision_at_k`, `recall_at_k`, `mrr`, `ndcg`, and `hit_rate` are deterministic and bias-free
352
402
 
353
403
  ## Requirements
354
404
 
355
405
  - Ruby >= 3.4
356
- - [ruby_llm](https://github.com/crmne/ruby_llm) ~> 1.0
406
+ - [ruby_llm](https://github.com/crmne/ruby_llm) = 2.0.0.rc1 for RubricLLM 0.6.0.rc1
357
407
  - An API key for your chosen LLM provider (set via RubyLLM configuration)
358
408
 
359
409
  ## Contributing
@@ -7,12 +7,6 @@ module RubricLLM
7
7
  def initialize(report_a, report_b)
8
8
  @report_a = report_a
9
9
  @report_b = report_b
10
-
11
- return if report_a.results.size == report_b.results.size
12
-
13
- warn "[RubricLLM] Comparison reports have different sizes " \
14
- "(#{report_a.results.size} vs #{report_b.results.size}). " \
15
- "Unmatched pairs will be dropped."
16
10
  end
17
11
 
18
12
  def results
@@ -21,24 +15,27 @@ module RubricLLM
21
15
 
22
16
  def summary
23
17
  lines = ["A/B Comparison"]
24
- lines << ("=" * 70)
25
- lines << "Metric A B Delta p-value Sig"
26
- lines << ("-" * 70)
18
+ lines << ("=" * 80)
19
+ lines << "Metric A B Delta p-value p-adj Sig"
20
+ lines << ("-" * 80)
27
21
 
28
22
  results.each do |metric, r|
29
- lines << format("%-20s %8.3f %8.3f %+8.3f %10.4f %4s",
30
- metric, r[:mean_a], r[:mean_b], r[:delta], r[:p_value], r[:significance])
23
+ lines << format("%-20s %8.3f %8.3f %+8.3f %10.4f %10.4f %4s",
24
+ metric, r[:mean_a], r[:mean_b], r[:delta], r[:p_value], r[:p_value_adjusted],
25
+ r[:significance])
31
26
  end
32
27
 
28
+ lines << ""
29
+ lines << "p-adj: Holm-Bonferroni adjusted across #{results.size} metrics. Significance uses p-adj."
33
30
  lines.join("\n")
34
31
  end
35
32
 
36
33
  def significant_improvements(alpha: 0.05)
37
- results.select { |_, r| r[:p_value] < alpha && r[:delta].positive? }.keys
34
+ results.select { |_, r| r[:p_value_adjusted] < alpha && r[:delta].positive? }.keys
38
35
  end
39
36
 
40
37
  def significant_regressions(alpha: 0.05)
41
- results.select { |_, r| r[:p_value] < alpha && r[:delta].negative? }.keys
38
+ results.select { |_, r| r[:p_value_adjusted] < alpha && r[:delta].negative? }.keys
42
39
  end
43
40
 
44
41
  private
@@ -46,110 +43,98 @@ module RubricLLM
46
43
  def compute_results
47
44
  metrics = (report_a.metric_stats.keys | report_b.metric_stats.keys)
48
45
 
49
- metrics.each_with_object({}) do |metric, hash|
50
- paired_scores = report_a.scores_for(metric)
51
- .zip(report_b.scores_for(metric))
52
- .select { |score_a, score_b| !score_a.nil? && !score_b.nil? }
46
+ raw = metrics.each_with_object({}) do |metric, hash|
47
+ stats = metric_stats(metric)
48
+ hash[metric] = stats if stats
49
+ end
50
+
51
+ apply_holm_correction(raw)
52
+ end
53
53
 
54
- next if paired_scores.empty?
54
+ def metric_stats(metric)
55
+ paired_scores = paired_results
56
+ .map { |result_a, result_b| [result_a.scores[metric], result_b.scores[metric]] }
57
+ .reject { |score_a, score_b| score_a.nil? || score_b.nil? }
55
58
 
56
- scores_a, scores_b = paired_scores.transpose
59
+ return nil if paired_scores.empty?
57
60
 
58
- mean_a = scores_a.sum / scores_a.size.to_f
59
- mean_b = scores_b.sum / scores_b.size.to_f
60
- delta = mean_b - mean_a
61
- p_value = paired_t_test(scores_a, scores_b)
61
+ scores_a, scores_b = paired_scores.transpose
62
+ mean_a = scores_a.sum / scores_a.size.to_f
63
+ mean_b = scores_b.sum / scores_b.size.to_f
62
64
 
63
- hash[metric] = {
64
- mean_a:,
65
- mean_b:,
66
- delta:,
67
- p_value:,
68
- significance: significance_marker(p_value)
69
- }
70
- end
65
+ {
66
+ mean_a:,
67
+ mean_b:,
68
+ delta: mean_b - mean_a,
69
+ p_value: Statistics.paired_t_test(scores_a, scores_b)
70
+ }
71
71
  end
72
72
 
73
- def paired_t_test(a, b)
74
- n = [a.size, b.size].min
75
- return 1.0 if n < 2
73
+ # A paired t-test requires the same sample on both sides. Pair by question
74
+ # instead of array position so a reordered dataset stays valid.
75
+ def paired_results
76
+ @paired_results ||= build_pairs
77
+ end
78
+
79
+ def build_pairs
80
+ groups_a = report_a.results.group_by { |result| pair_key(result) }
81
+ groups_b = report_b.results.group_by { |result| pair_key(result) }
76
82
 
77
- diffs = a.first(n).zip(b.first(n)).map { |x, y| y - x }
78
- mean_d = diffs.sum / n.to_f
79
- var_d = diffs.sum { |d| (d - mean_d)**2 } / (n - 1).to_f
80
- se = Math.sqrt(var_d / n)
83
+ warn_unkeyed(groups_a[nil].to_a.size + groups_b[nil].to_a.size)
81
84
 
82
- return 1.0 if se.zero?
85
+ matched = groups_a.keys & groups_b.keys
86
+ warn_unmatched(((groups_a.keys | groups_b.keys) - matched).compact)
83
87
 
84
- t = mean_d / se
85
- df = n - 1
88
+ matched.flat_map do |key|
89
+ list_a = groups_a[key]
90
+ list_b = groups_b[key]
91
+ warn_uneven(key, list_a.size, list_b.size) unless list_a.size == list_b.size
86
92
 
87
- # Two-tailed p-value approximation using Student's t-distribution
88
- two_tailed_p(t.abs, df)
93
+ size = [list_a.size, list_b.size].min
94
+ list_a.first(size).zip(list_b.first(size))
95
+ end
89
96
  end
90
97
 
91
- # Two-tailed p-value for Student's t-distribution.
92
- # p = I_x(df/2, 1/2) where x = df/(df + t²)
93
- def two_tailed_p(t, df)
94
- x = df / (df + (t**2))
95
- regularized_beta(x, df / 2.0, 0.5)
96
- rescue StandardError
97
- 1.0
98
+ def pair_key(result)
99
+ sample = result.sample
100
+ sample.is_a?(Hash) ? sample[:question] : nil
98
101
  end
99
102
 
100
- # Regularized incomplete beta function via continued fraction (Lentz's method).
101
- def regularized_beta(x, a, b)
102
- return 0.0 if x <= 0.0
103
- return 1.0 if x >= 1.0
103
+ # Results with no sample[:question] share one bucket and pair by position,
104
+ # which is the behaviour identity pairing exists to replace. Say so.
105
+ def warn_unkeyed(count)
106
+ return if count.zero?
104
107
 
105
- ln_beta = Math.lgamma(a + b)[0] - Math.lgamma(a)[0] - Math.lgamma(b)[0]
106
- front = Math.exp(ln_beta + (a * Math.log(x)) + (b * Math.log(1.0 - x)))
108
+ warn "[RubricLLM] #{count} result(s) have no sample[:question]. They share one bucket and " \
109
+ "pair by position. Give every sample a :question to pair them reliably."
110
+ end
111
+
112
+ def warn_unmatched(keys)
113
+ return if keys.empty?
107
114
 
108
- result = if x < ((a + 1.0) / (a + b + 2.0))
109
- front * beta_continued_fraction(a, b, x) / a
110
- else
111
- 1.0 - ((front * beta_continued_fraction(b, a, 1.0 - x)) / b)
112
- end
115
+ warn "[RubricLLM] Comparison dropped #{keys.size} question(s) present in only one report. " \
116
+ "Paired tests need the same questions on both sides."
117
+ end
113
118
 
114
- result.clamp(0.0, 1.0)
119
+ def warn_uneven(key, size_a, size_b)
120
+ warn "[RubricLLM] Question #{key.inspect} appears #{size_a} time(s) in report A and " \
121
+ "#{size_b} time(s) in report B. Extra occurrences are dropped."
115
122
  end
116
123
 
117
- def beta_continued_fraction(a, b, x)
118
- tiny = 1e-30
119
- qab = a + b
120
- qap = a + 1.0
121
- qam = a - 1.0
122
-
123
- c = 1.0
124
- d = 1.0 - ((qab * x) / qap)
125
- d = tiny if d.abs < tiny
126
- d = 1.0 / d
127
- fraction = d
128
-
129
- (1..200).each do |m|
130
- m2 = 2 * m
131
-
132
- numerator = (m * (b - m) * x) / ((qam + m2) * (a + m2))
133
- d = 1.0 + (numerator * d)
134
- d = tiny if d.abs < tiny
135
- c = 1.0 + (numerator / c)
136
- c = tiny if c.abs < tiny
137
- d = 1.0 / d
138
- fraction *= c * d
139
-
140
- numerator = -((a + m) * (qab + m) * x) / ((a + m2) * (qap + m2))
141
- d = 1.0 + (numerator * d)
142
- d = tiny if d.abs < tiny
143
- c = 1.0 + (numerator / c)
144
- c = tiny if c.abs < tiny
145
- d = 1.0 / d
146
- delta = c * d
147
- fraction *= delta
148
-
149
- break if (delta - 1.0).abs < 1e-12
124
+ # One t-test per metric inflates the family-wise error rate, so adjust
125
+ # before calling any metric significant.
126
+ def apply_holm_correction(results)
127
+ metrics = results.keys
128
+ adjusted = Statistics.holm_adjust(metrics.map { |metric| results[metric][:p_value] })
129
+
130
+ metrics.each_with_index do |metric, index|
131
+ results[metric] = results[metric].merge(
132
+ p_value_adjusted: adjusted[index],
133
+ significance: significance_marker(adjusted[index])
134
+ )
150
135
  end
151
136
 
152
- fraction
137
+ results
153
138
  end
154
139
 
155
140
  def significance_marker(p)
@@ -5,11 +5,12 @@ module RubricLLM
5
5
  attr_accessor :judge_model, :judge_provider, :temperature, :max_tokens, :custom_prompt,
6
6
  :max_retries, :retry_base_delay, :concurrency
7
7
 
8
- def initialize(judge_model: nil, judge_provider: nil, temperature: nil, max_tokens: nil, # rubocop:disable Metrics/ParameterLists
8
+ def initialize(judge_model: nil, judge_provider: nil, # rubocop:disable Metrics/ParameterLists
9
+ temperature: Float(ENV.fetch("RUBRIC_TEMPERATURE", "0.0")), max_tokens: nil,
9
10
  custom_prompt: nil, max_retries: nil, retry_base_delay: nil, concurrency: nil, validate: false)
10
11
  @judge_model = judge_model || ENV.fetch("RUBRIC_JUDGE_MODEL", "gpt-4o")
11
12
  @judge_provider = (judge_provider || ENV.fetch("RUBRIC_JUDGE_PROVIDER", "openai")).to_sym
12
- @temperature = temperature || Float(ENV.fetch("RUBRIC_TEMPERATURE", "0.0"))
13
+ @temperature = temperature
13
14
  @max_tokens = max_tokens || Integer(ENV.fetch("RUBRIC_MAX_TOKENS", "4096"))
14
15
  @custom_prompt = custom_prompt
15
16
  @max_retries = max_retries || Integer(ENV.fetch("RUBRIC_MAX_RETRIES", "2"))
@@ -61,9 +62,9 @@ module RubricLLM
61
62
  end
62
63
 
63
64
  def validate_temperature
64
- return if temperature.is_a?(Numeric) && temperature.between?(0.0, 2.0)
65
+ return if temperature.nil? || (temperature.is_a?(Numeric) && temperature.between?(0.0, 2.0))
65
66
 
66
- raise ConfigurationError, "temperature must be between 0.0 and 2.0"
67
+ raise ConfigurationError, "temperature must be nil or between 0.0 and 2.0"
67
68
  end
68
69
 
69
70
  def validate_max_tokens
@@ -4,6 +4,20 @@ require "json"
4
4
 
5
5
  module RubricLLM
6
6
  class Judge
7
+ # Failures worth retrying: the same request may succeed later.
8
+ # Everything else (bad key, no credit, malformed request, prompt too long,
9
+ # contract violations in the judge response) fails on the first attempt.
10
+ #
11
+ # Transport failures are absent on purpose. RubyLLM's connection already
12
+ # retries timeouts and connection resets, so by the time one reaches us it
13
+ # has been tried several times and is not worth another round.
14
+ TRANSIENT_ERRORS = [
15
+ RubyLLM::RateLimitError,
16
+ RubyLLM::ServerError,
17
+ RubyLLM::ServiceUnavailableError,
18
+ RubyLLM::OverloadedError
19
+ ].freeze
20
+
7
21
  METRIC_RESPONSE_SCHEMA = {
8
22
  name: "rubric_llm_metric_response",
9
23
  strict: false,
@@ -37,20 +51,15 @@ module RubricLLM
37
51
  attempts += 1
38
52
  chat = RubyLLM.chat(model: config.judge_model, provider: config.judge_provider)
39
53
  chat.with_temperature(config.temperature)
40
- chat.with_params(max_tokens: config.max_tokens)
54
+ chat.with_max_output_tokens(config.max_tokens)
41
55
  apply_response_schema(chat)
42
56
 
43
57
  full_system_prompt = build_system_prompt(system_prompt)
44
58
  chat.with_instructions(full_system_prompt)
45
59
  response = chat.ask(user_prompt)
46
- content = response.content
47
- validate_response!(content.is_a?(Hash) ? content : parse_json(content))
60
+ validate_response!(parse_json(response.content))
48
61
  rescue StandardError => e
49
- if attempts > config.max_retries
50
- raise e if e.is_a?(JudgeError)
51
-
52
- raise JudgeError, "Judge call failed: #{e.message}"
53
- end
62
+ raise wrap_error(e) unless transient?(e) && attempts <= config.max_retries
54
63
 
55
64
  sleep(config.retry_base_delay * (2**(attempts - 1)))
56
65
  retry
@@ -79,18 +88,24 @@ module RubricLLM
79
88
 
80
89
  private
81
90
 
91
+ def transient?(error)
92
+ TRANSIENT_ERRORS.any? { |klass| error.is_a?(klass) }
93
+ end
94
+
95
+ def wrap_error(error)
96
+ return error if error.is_a?(JudgeError)
97
+
98
+ JudgeError.new("Judge call failed: #{error.message}")
99
+ end
100
+
82
101
  def apply_response_schema(chat)
83
- return chat unless chat.respond_to?(:with_schema)
84
102
  return chat unless structured_output_supported?(chat)
85
103
 
86
104
  chat.with_schema(METRIC_RESPONSE_SCHEMA)
87
105
  end
88
106
 
89
107
  def structured_output_supported?(chat)
90
- return true unless chat.respond_to?(:model)
91
- return true unless chat.model.respond_to?(:structured_output?)
92
-
93
- chat.model.structured_output?
108
+ chat.model.supports?(:structured_output)
94
109
  end
95
110
 
96
111
  def validate_response!(response)
@@ -3,6 +3,20 @@
3
3
  module RubricLLM
4
4
  module Metrics
5
5
  class Base
6
+ # Single source of truth for what counts as usable context.
7
+ # Blank and whitespace-only chunks are dropped.
8
+ def self.normalize_context(context)
9
+ Array(context).map { |chunk| chunk.to_s.strip }.reject(&:empty?)
10
+ end
11
+
12
+ # An empty context cannot produce a faithfulness score. Reject it as a caller
13
+ # error instead of letting a nil score read as a quality verdict.
14
+ def self.require_context!(context)
15
+ return unless normalize_context(context).empty?
16
+
17
+ raise ArgumentError, "context must contain at least one non-empty entry"
18
+ end
19
+
6
20
  attr_reader :judge
7
21
 
8
22
  def initialize(judge:)
@@ -16,13 +16,14 @@ module RubricLLM
16
16
  PROMPT
17
17
 
18
18
  def call(question:, context: [], **)
19
- return { score: nil, details: { error: "No context provided" } } if Array(context).empty?
19
+ context_chunks = Base.normalize_context(context)
20
+ return { score: nil, details: { error: "No context provided" } } if context_chunks.empty?
20
21
 
21
22
  user_prompt = <<~PROMPT
22
23
  Question: #{question}
23
24
 
24
25
  Contexts:
25
- #{Array(context).each_with_index.map { |c, i| "#{i + 1}. #{c}" }.join("\n")}
26
+ #{context_chunks.each_with_index.map { |c, i| "#{i + 1}. #{c}" }.join("\n")}
26
27
 
27
28
  Evaluate how relevant each context is to the question.
28
29
  PROMPT
@@ -17,11 +17,13 @@ module RubricLLM
17
17
 
18
18
  def call(context: [], ground_truth: nil, **)
19
19
  return { score: nil, details: { error: "No ground truth provided" } } if ground_truth.nil?
20
- return { score: nil, details: { error: "No context provided" } } if Array(context).empty?
20
+
21
+ context_chunks = Base.normalize_context(context)
22
+ return { score: nil, details: { error: "No context provided" } } if context_chunks.empty?
21
23
 
22
24
  user_prompt = <<~PROMPT
23
25
  Contexts:
24
- #{Array(context).each_with_index.map { |c, i| "#{i + 1}. #{c}" }.join("\n")}
26
+ #{context_chunks.each_with_index.map { |c, i| "#{i + 1}. #{c}" }.join("\n")}
25
27
 
26
28
  Ground Truth: #{ground_truth}
27
29
 
@@ -16,7 +16,7 @@ module RubricLLM
16
16
  PROMPT
17
17
 
18
18
  def call(question:, answer:, context: [], **)
19
- context_chunks = Array(context).map { |chunk| chunk.to_s.strip }.reject(&:empty?)
19
+ context_chunks = Base.normalize_context(context)
20
20
  return { score: nil, details: { error: "No context provided" } } if context_chunks.empty?
21
21
 
22
22
  user_prompt = <<~PROMPT
@@ -4,7 +4,8 @@ require "rubric_llm"
4
4
 
5
5
  module RubricLLM
6
6
  module Assertions
7
- def assert_faithful(answer, context, question: "", threshold: 0.8, config: RubricLLM.config)
7
+ def assert_faithful(answer, context, question: "", threshold: DEFAULT_THRESHOLD, config: RubricLLM.config)
8
+ require_context!(context)
8
9
  result = evaluate_metric(Metrics::Faithfulness, question:, answer:, context:, config:)
9
10
  score = result[:score]
10
11
 
@@ -12,7 +13,7 @@ module RubricLLM
12
13
  "Expected faithfulness >= #{threshold}, got #{score || "nil"}.#{failure_details(result)}"
13
14
  end
14
15
 
15
- def assert_relevant(question, answer, threshold: 0.8, config: RubricLLM.config)
16
+ def assert_relevant(question, answer, threshold: DEFAULT_THRESHOLD, config: RubricLLM.config)
16
17
  result = evaluate_metric(Metrics::Relevance, question:, answer:, config:)
17
18
  score = result[:score]
18
19
 
@@ -20,7 +21,7 @@ module RubricLLM
20
21
  "Expected relevance >= #{threshold}, got #{score || "nil"}.#{failure_details(result)}"
21
22
  end
22
23
 
23
- def assert_correct(answer, ground_truth, question: "", threshold: 0.8, config: RubricLLM.config)
24
+ def assert_correct(answer, ground_truth, question: "", threshold: DEFAULT_THRESHOLD, config: RubricLLM.config)
24
25
  result = evaluate_metric(Metrics::Correctness, question:, answer:, ground_truth:, config:)
25
26
  score = result[:score]
26
27
 
@@ -28,7 +29,8 @@ module RubricLLM
28
29
  "Expected correctness >= #{threshold}, got #{score || "nil"}.#{failure_details(result)}"
29
30
  end
30
31
 
31
- def refute_hallucination(answer, context, question: "", threshold: 0.8, config: RubricLLM.config)
32
+ def refute_hallucination(answer, context, question: "", threshold: DEFAULT_THRESHOLD, config: RubricLLM.config)
33
+ require_context!(context)
32
34
  result = evaluate_metric(Metrics::Faithfulness, question:, answer:, context:, config:)
33
35
  score = result[:score]
34
36
 
@@ -38,6 +40,10 @@ module RubricLLM
38
40
 
39
41
  private
40
42
 
43
+ def require_context!(context)
44
+ Metrics::Base.require_context!(context)
45
+ end
46
+
41
47
  def evaluate_metric(metric_class, config:, **)
42
48
  judge = Judge.new(config:)
43
49
  metric = metric_class.new(judge:)
@@ -19,7 +19,7 @@ module RubricLLM
19
19
  results.sort_by { |r| r.overall || -Float::INFINITY }.first(n)
20
20
  end
21
21
 
22
- def failures(threshold: 0.8)
22
+ def failures(threshold: DEFAULT_THRESHOLD)
23
23
  results.reject { |r| r.pass?(threshold:) }
24
24
  end
25
25
 
@@ -17,7 +17,7 @@ module RubricLLM
17
17
  valid.sum / valid.size.to_f
18
18
  end
19
19
 
20
- def pass?(threshold: 0.8)
20
+ def pass?(threshold: DEFAULT_THRESHOLD)
21
21
  return false unless valid?
22
22
 
23
23
  score = overall
@@ -24,7 +24,7 @@ module RubricLLM
24
24
  attr_reader :threshold, :config, :result
25
25
 
26
26
  def initialize
27
- @threshold = 0.8
27
+ @threshold = DEFAULT_THRESHOLD
28
28
  @config = RubricLLM.config
29
29
  end
30
30
 
@@ -40,6 +40,10 @@ module RubricLLM
40
40
 
41
41
  private
42
42
 
43
+ def require_context!(context)
44
+ Metrics::Base.require_context!(context)
45
+ end
46
+
43
47
  def evaluate(metric_class, **)
44
48
  judge = Judge.new(config:)
45
49
  metric = metric_class.new(judge:)
@@ -51,6 +55,7 @@ module RubricLLM
51
55
  class FaithfulnessMatcher < BaseMatcher
52
56
  def initialize(context, question: nil)
53
57
  super()
58
+ require_context!(context)
54
59
  @context = context
55
60
  @question = question
56
61
  end
@@ -113,17 +118,19 @@ module RubricLLM
113
118
  class HallucinationMatcher < BaseMatcher
114
119
  def initialize(context, question: nil)
115
120
  super()
121
+ require_context!(context)
116
122
  @context = context
117
123
  @question = question
118
124
  end
119
125
 
126
+ # Fail closed: a missing score is not evidence of a hallucination.
120
127
  def matches?(answer)
121
128
  score = evaluate(Metrics::Faithfulness, question: @question || "", answer:, context: @context)
122
- score.nil? || score < threshold
129
+ !score.nil? && score < threshold
123
130
  end
124
131
 
125
132
  def failure_message
126
- "expected hallucination (faithfulness < #{threshold}), got #{result[:score]}"
133
+ "expected hallucination (faithfulness < #{threshold}), got #{result[:score] || "nil"}"
127
134
  end
128
135
 
129
136
  def failure_message_when_negated
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubricLLM
4
+ # Pure statistical helpers. No LLM calls, no state.
5
+ module Statistics
6
+ module_function
7
+
8
+ # Two-tailed paired t-test. Both arrays must already be paired and equal length.
9
+ # Returns 1.0 when there is nothing to test.
10
+ def paired_t_test(scores_a, scores_b)
11
+ n = scores_a.size
12
+ return 1.0 if n < 2
13
+
14
+ diffs = scores_a.zip(scores_b).map { |x, y| y - x }
15
+ mean_d = diffs.sum / n.to_f
16
+ var_d = diffs.sum { |d| (d - mean_d)**2 } / (n - 1).to_f
17
+ se = Math.sqrt(var_d / n)
18
+
19
+ # A constant shift has no spread to test. Compare against the scale of the
20
+ # data rather than exact zero, or float error turns it into a huge t value.
21
+ return 1.0 if se <= Float::EPSILON * [mean_d.abs, 1.0].max
22
+
23
+ two_tailed_p((mean_d / se).abs, n - 1)
24
+ end
25
+
26
+ # Two-tailed p-value for Student's t-distribution.
27
+ # p = I_x(df/2, 1/2) where x = df/(df + t²)
28
+ def two_tailed_p(t, df)
29
+ x = df / (df + (t**2))
30
+ regularized_beta(x, df / 2.0, 0.5)
31
+ rescue Math::DomainError, ZeroDivisionError, FloatDomainError
32
+ 1.0
33
+ end
34
+
35
+ # Holm-Bonferroni step-down adjustment.
36
+ # Takes p-values in any order, returns adjusted values in the same order.
37
+ def holm_adjust(p_values)
38
+ count = p_values.size
39
+ return p_values.dup if count.zero?
40
+
41
+ adjusted = Array.new(count)
42
+ running_max = 0.0
43
+
44
+ p_values.each_with_index.sort_by(&:first).each_with_index do |(p_value, position), rank|
45
+ running_max = ((count - rank) * p_value).clamp(running_max, 1.0)
46
+ adjusted[position] = running_max
47
+ end
48
+
49
+ adjusted
50
+ end
51
+
52
+ # Regularized incomplete beta function via continued fraction (Lentz's method).
53
+ def regularized_beta(x, a, b)
54
+ return 0.0 if x <= 0.0
55
+ return 1.0 if x >= 1.0
56
+
57
+ ln_beta = Math.lgamma(a + b)[0] - Math.lgamma(a)[0] - Math.lgamma(b)[0]
58
+ front = Math.exp(ln_beta + (a * Math.log(x)) + (b * Math.log(1.0 - x)))
59
+
60
+ result = if x < ((a + 1.0) / (a + b + 2.0))
61
+ front * beta_continued_fraction(a, b, x) / a
62
+ else
63
+ 1.0 - ((front * beta_continued_fraction(b, a, 1.0 - x)) / b)
64
+ end
65
+
66
+ result.clamp(0.0, 1.0)
67
+ end
68
+
69
+ def beta_continued_fraction(a, b, x)
70
+ tiny = 1e-30
71
+ qab = a + b
72
+ qap = a + 1.0
73
+ qam = a - 1.0
74
+
75
+ c = 1.0
76
+ d = 1.0 - ((qab * x) / qap)
77
+ d = tiny if d.abs < tiny
78
+ d = 1.0 / d
79
+ fraction = d
80
+
81
+ (1..200).each do |m|
82
+ m2 = 2 * m
83
+
84
+ numerator = (m * (b - m) * x) / ((qam + m2) * (a + m2))
85
+ d = 1.0 + (numerator * d)
86
+ d = tiny if d.abs < tiny
87
+ c = 1.0 + (numerator / c)
88
+ c = tiny if c.abs < tiny
89
+ d = 1.0 / d
90
+ fraction *= c * d
91
+
92
+ numerator = -((a + m) * (qab + m) * x) / ((a + m2) * (qap + m2))
93
+ d = 1.0 + (numerator * d)
94
+ d = tiny if d.abs < tiny
95
+ c = 1.0 + (numerator / c)
96
+ c = tiny if c.abs < tiny
97
+ d = 1.0 / d
98
+ delta = c * d
99
+ fraction *= delta
100
+
101
+ break if (delta - 1.0).abs < 1e-12
102
+ end
103
+
104
+ fraction
105
+ end
106
+ end
107
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubricLLM
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0.rc1"
5
5
  end
data/lib/rubric_llm.rb CHANGED
@@ -16,10 +16,13 @@ require_relative "rubric_llm/metrics/factual_accuracy"
16
16
  require_relative "rubric_llm/result"
17
17
  require_relative "rubric_llm/evaluator"
18
18
  require_relative "rubric_llm/report"
19
+ require_relative "rubric_llm/statistics"
19
20
  require_relative "rubric_llm/comparison"
20
21
  require_relative "rubric_llm/retrieval_result"
21
22
 
22
23
  module RubricLLM
24
+ DEFAULT_THRESHOLD = 0.8
25
+
23
26
  class << self
24
27
  def config
25
28
  @config ||= Config.new
@@ -140,7 +143,7 @@ module RubricLLM
140
143
  def apply_custom_prompt(config, custom_prompt)
141
144
  return config unless custom_prompt
142
145
 
143
- Config.new(**config.to_h.compact, custom_prompt:)
146
+ Config.new(**config.to_h, custom_prompt:)
144
147
  end
145
148
 
146
149
  def normalize_sample(sample)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubric_llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Paluy
@@ -27,16 +27,16 @@ dependencies:
27
27
  name: ruby_llm
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
- - - "~>"
30
+ - - '='
31
31
  - !ruby/object:Gem::Version
32
- version: '1.16'
32
+ version: 2.0.0.rc1
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
- - - "~>"
37
+ - - '='
38
38
  - !ruby/object:Gem::Version
39
- version: '1.16'
39
+ version: 2.0.0.rc1
40
40
  description: Provider-agnostic LLM evaluation with pluggable metrics, statistical
41
41
  A/B comparison, and test framework integration. Ragas for Ruby, powered by RubyLLM.
42
42
  email:
@@ -69,6 +69,7 @@ files:
69
69
  - lib/rubric_llm/result.rb
70
70
  - lib/rubric_llm/retrieval_result.rb
71
71
  - lib/rubric_llm/rspec.rb
72
+ - lib/rubric_llm/statistics.rb
72
73
  - lib/rubric_llm/version.rb
73
74
  homepage: https://github.com/dpaluy/rubric_llm
74
75
  licenses: