rubric_llm 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4afa554e2e9ae10520d0cc605f55d59cdc6330c7ba65f38c4d3e933fb11519a4
4
- data.tar.gz: e371eaaf0ab368c46d930e352434664bca19315bfc45865601686b0a5525ca5a
3
+ metadata.gz: e4b3be22ad7ef0d4af66ddba820a505c75c29f3bf47e1259bdee1ed28cf87ae4
4
+ data.tar.gz: 2250b2b15b76203042befc1a86805827161c0df49efaf8dc240c5c594d5166b3
5
5
  SHA512:
6
- metadata.gz: f635136ea65b903555493b377a14d5902b2be949ef7bc77b036450ac25d8ead70429039c5f47ffdc36c2216470e39409ceb50f35c609df8f4e5648b64569fa38
7
- data.tar.gz: a66697e131141d27ff0c406fdd60b6ef103c4c69cce3b85a3c5cf481cbdf55cc489983b10e94ffe7557ab0a637a8c09530e9cd0f317e16e092496862574fed1c
6
+ metadata.gz: 9e7e813f4bf58b17b70b6da5b42f0e77e962cc2862a1b5a62e59c5152ebc8de0c25058842c1c92344704e5fd249846a8662bd9937ad40ef068464c3520c3fee0
7
+ data.tar.gz: aa9f1156c49f63cef0f00a7c649b14a6b11d9bec8904962e5a25af88bafd70447c797d7bb1f69ddffd5b17f13dda2e5e702fed5aede15c4cd9ef59c871c1be52
data/CHANGELOG.md CHANGED
@@ -5,6 +5,43 @@ 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.5.0] - 2026-08-23
9
+
10
+ ### Fixed
11
+
12
+ - 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
13
+ - `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
14
+ - `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
15
+ - `ContextPrecision` and `ContextRecall` drop blank and whitespace-only context chunks and return a `No context provided` error when nothing usable remains, matching `Faithfulness`
16
+ - `Statistics.two_tailed_p` rescues only `Math::DomainError`, `ZeroDivisionError`, and `FloatDomainError`. It previously swallowed every `StandardError` and returned 1.0
17
+ - 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
18
+ - The RSpec `hallucinate` matcher renders a missing score as `nil` in its failure message instead of an empty string
19
+
20
+ ### Removed
21
+
22
+ - 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
23
+
24
+ ### Added
25
+
26
+ - 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%
27
+ - `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
28
+ - `Metrics::Base.normalize_context`, the single definition of usable context
29
+
30
+ ### Changed
31
+
32
+ - 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
33
+
34
+ ## [0.4.0] - 2026-07-11
35
+
36
+ ### Added
37
+
38
+ - `RubricLLM.evaluate_batch` validates every sample upfront (must be a Hash with non-nil `:question` and `:answer`, string or symbol keys) and raises `ArgumentError` with the offending index before any LLM call, so sequential and concurrent modes fail identically and without API spend
39
+
40
+ ### Changed
41
+
42
+ - Add `csv` as a runtime dependency; `csv` moved from a default gem to a bundled gem in Ruby 3.4, so consumers previously hit a `LoadError` on `Report#export_csv`
43
+ - Require `ruby_llm ~> 1.16`
44
+
8
45
  ## [0.3.0] - 2026-07-11
9
46
 
10
47
  ### Changed
data/README.md CHANGED
@@ -6,7 +6,7 @@ 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
10
 
11
11
  ## Installation
12
12
 
@@ -116,14 +116,14 @@ These metrics use a judge LLM to evaluate quality. Each sends a structured promp
116
116
  | **Faithfulness** | Is every claim in the answer supported by the context? | `context` |
117
117
 
118
118
  ```ruby
119
- # Only context gets faithfulness, relevance, context_precision
119
+ # Only context (gets faithfulness, relevance, context_precision)
120
120
  result = RubricLLM.evaluate(
121
121
  question: "How does photosynthesis work?",
122
122
  answer: "Plants convert sunlight into energy.",
123
123
  context: ["Photosynthesis is the process by which plants convert light energy into chemical energy."]
124
124
  )
125
125
 
126
- # With ground truth gets all metrics
126
+ # With ground truth (gets all metrics)
127
127
  result = RubricLLM.evaluate(
128
128
  question: "How does photosynthesis work?",
129
129
  answer: "Plants convert sunlight into energy.",
@@ -155,7 +155,7 @@ result.scores[:tone_metric] # => 0.85
155
155
 
156
156
  ### Retrieval Metrics
157
157
 
158
- Pure math no LLM calls, no API key needed.
158
+ Pure math, no LLM calls, no API key needed.
159
159
 
160
160
  ```ruby
161
161
  result = RubricLLM.evaluate_retrieval(
@@ -164,9 +164,9 @@ result = RubricLLM.evaluate_retrieval(
164
164
  )
165
165
 
166
166
  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
167
+ result.recall_at_k(3) # => 1.0
168
+ result.mrr # => 1.0
169
+ result.ndcg # => 0.92
170
170
  result.hit_rate # => 1.0
171
171
  ```
172
172
 
@@ -217,12 +217,14 @@ comparison = RubricLLM.compare(report_a, report_b)
217
217
 
218
218
  puts comparison.summary
219
219
  # 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 **
220
+ # ================================================================================
221
+ # Metric A B Delta p-value p-adj Sig
222
+ # --------------------------------------------------------------------------------
223
+ # faithfulness 0.880 0.920 +0.040 0.0023 0.0068 **
224
+ # relevance 0.850 0.860 +0.010 0.3081 0.3081
225
+ # correctness 0.910 0.940 +0.030 0.0240 0.0480 *
226
+ #
227
+ # p-adj: Holm-Bonferroni adjusted across 3 metrics. Significance uses p-adj.
226
228
 
227
229
  comparison.significant_improvements # => [:faithfulness, :correctness]
228
230
  comparison.significant_regressions # => []
@@ -230,6 +232,16 @@ comparison.significant_regressions # => []
230
232
 
231
233
  Significance markers: `*` (p < 0.05), `**` (p < 0.01), `***` (p < 0.001)
232
234
 
235
+ ### Pairing
236
+
237
+ 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.
238
+
239
+ `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.
240
+
241
+ ### Multiple comparisons
242
+
243
+ 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.
244
+
233
245
  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
246
 
235
247
  ## Test Integration
@@ -298,7 +310,7 @@ rescue RubricLLM::Error => e
298
310
  end
299
311
  ```
300
312
 
301
- Individual metric failures are handled gracefully a failed metric returns `nil` for the score and includes the error in details:
313
+ Individual metric failures are handled gracefully: a failed metric returns `nil` for the score and includes the error in details:
302
314
 
303
315
  ```ruby
304
316
  result = RubricLLM.evaluate(question: "q", answer: "a")
@@ -317,12 +329,12 @@ bundle exec rubocop
317
329
 
318
330
  ## Limitations
319
331
 
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.
332
+ 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
333
 
322
334
  Mitigations built into the framework:
323
335
 
324
336
  - **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).
337
+ - **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
338
  - **Custom non-LLM metrics.** Subclass `Metrics::Base` with regex checks, embedding similarity, or any deterministic logic.
327
339
  - **Statistical comparison.** A/B testing with paired t-tests surfaces systematic judge bias across runs.
328
340
 
@@ -338,7 +350,7 @@ Ruby has two LLM evaluation options today. Neither fits most use cases:
338
350
  | **LLM access** | Raw HTTP (OpenAI/Anthropic only) | You implement it | RubyLLM (any provider) |
339
351
  | **Rails required?** | No | Yes (engine + 6 migrations) | No |
340
352
  | **ActiveRecord?** | No | Yes | No |
341
- | **A/B comparison** | Basic | No | Paired t-test with p-values |
353
+ | **A/B comparison** | Basic | No | Paired t-test with Holm-corrected p-values |
342
354
  | **Test assertions** | Minitest + RSpec | No | Minitest + RSpec |
343
355
  | **Pluggable metrics** | No (fixed set) | Yes | Yes |
344
356
  | **Retrieval metrics** | Yes | No | Yes |
@@ -347,8 +359,8 @@ Ruby has two LLM evaluation options today. Neither fits most use cases:
347
359
 
348
360
  Deep dives live in the [project wiki](https://github.com/dpaluy/rubric_llm/wiki):
349
361
 
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
362
+ - [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
363
+ - [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
364
 
353
365
  ## Requirements
354
366
 
@@ -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)
@@ -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,
@@ -46,11 +60,7 @@ module RubricLLM
46
60
  content = response.content
47
61
  validate_response!(content.is_a?(Hash) ? content : parse_json(content))
48
62
  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
63
+ raise wrap_error(e) unless transient?(e) && attempts <= config.max_retries
54
64
 
55
65
  sleep(config.retry_base_delay * (2**(attempts - 1)))
56
66
  retry
@@ -79,6 +89,16 @@ module RubricLLM
79
89
 
80
90
  private
81
91
 
92
+ def transient?(error)
93
+ TRANSIENT_ERRORS.any? { |klass| error.is_a?(klass) }
94
+ end
95
+
96
+ def wrap_error(error)
97
+ return error if error.is_a?(JudgeError)
98
+
99
+ JudgeError.new("Judge call failed: #{error.message}")
100
+ end
101
+
82
102
  def apply_response_schema(chat)
83
103
  return chat unless chat.respond_to?(:with_schema)
84
104
  return chat unless structured_output_supported?(chat)
@@ -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
 
@@ -42,7 +42,7 @@ module RubricLLM
42
42
  end
43
43
 
44
44
  def export_csv(path)
45
- require "csv" # optional dependency — add `gem "csv"` to your Gemfile if missing
45
+ require "csv"
46
46
  metrics = all_metric_names
47
47
  CSV.open(path, "w") do |csv|
48
48
  csv << ["question", "answer", "overall", *metrics]
@@ -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.3.0"
4
+ VERSION = "0.5.0"
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
@@ -56,6 +59,7 @@ module RubricLLM
56
59
  # report = RubricLLM.evaluate_batch(dataset)
57
60
  # report = RubricLLM.evaluate_batch(dataset, concurrency: 4)
58
61
  def evaluate_batch(dataset, metrics: nil, config: self.config, custom_prompt: nil, concurrency: nil)
62
+ validate_dataset!(dataset)
59
63
  config = apply_custom_prompt(config, custom_prompt)
60
64
  pool_size = concurrency || config.concurrency
61
65
  start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -87,6 +91,22 @@ module RubricLLM
87
91
 
88
92
  private
89
93
 
94
+ def validate_dataset!(dataset)
95
+ dataset.each_with_index do |sample, index|
96
+ raise ArgumentError, "sample at index #{index} is not a Hash" unless sample.is_a?(Hash)
97
+
98
+ question_present = sample.key?(:question) || sample.key?("question")
99
+ answer_present = sample.key?(:answer) || sample.key?("answer")
100
+ raise ArgumentError, "sample at index #{index} is missing :question" unless question_present
101
+ raise ArgumentError, "sample at index #{index} is missing :answer" unless answer_present
102
+
103
+ question_provided = !sample[:question].nil? || !sample["question"].nil?
104
+ answer_provided = !sample[:answer].nil? || !sample["answer"].nil?
105
+ raise ArgumentError, "sample at index #{index} has nil :question" unless question_provided
106
+ raise ArgumentError, "sample at index #{index} has nil :answer" unless answer_provided
107
+ end
108
+ end
109
+
90
110
  def evaluate_sample(evaluator, sample)
91
111
  sample = normalize_sample(sample)
92
112
  evaluator.call(
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.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Paluy
@@ -9,20 +9,34 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: csv
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: ruby_llm
14
28
  requirement: !ruby/object:Gem::Requirement
15
29
  requirements:
16
30
  - - "~>"
17
31
  - !ruby/object:Gem::Version
18
- version: '1.13'
32
+ version: '1.16'
19
33
  type: :runtime
20
34
  prerelease: false
21
35
  version_requirements: !ruby/object:Gem::Requirement
22
36
  requirements:
23
37
  - - "~>"
24
38
  - !ruby/object:Gem::Version
25
- version: '1.13'
39
+ version: '1.16'
26
40
  description: Provider-agnostic LLM evaluation with pluggable metrics, statistical
27
41
  A/B comparison, and test framework integration. Ragas for Ruby, powered by RubyLLM.
28
42
  email:
@@ -55,6 +69,7 @@ files:
55
69
  - lib/rubric_llm/result.rb
56
70
  - lib/rubric_llm/retrieval_result.rb
57
71
  - lib/rubric_llm/rspec.rb
72
+ - lib/rubric_llm/statistics.rb
58
73
  - lib/rubric_llm/version.rb
59
74
  homepage: https://github.com/dpaluy/rubric_llm
60
75
  licenses: