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 +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +31 -19
- data/lib/rubric_llm/comparison.rb +81 -96
- data/lib/rubric_llm/judge.rb +25 -5
- data/lib/rubric_llm/metrics/base.rb +14 -0
- data/lib/rubric_llm/metrics/context_precision.rb +3 -2
- data/lib/rubric_llm/metrics/context_recall.rb +4 -2
- data/lib/rubric_llm/metrics/faithfulness.rb +1 -1
- data/lib/rubric_llm/minitest.rb +10 -4
- data/lib/rubric_llm/report.rb +2 -2
- data/lib/rubric_llm/result.rb +1 -1
- data/lib/rubric_llm/rspec.rb +10 -3
- data/lib/rubric_llm/statistics.rb +107 -0
- data/lib/rubric_llm/version.rb +1 -1
- data/lib/rubric_llm.rb +20 -0
- metadata +18 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e4b3be22ad7ef0d4af66ddba820a505c75c29f3bf47e1259bdee1ed28cf87ae4
|
|
4
|
+
data.tar.gz: 2250b2b15b76203042befc1a86805827161c0df49efaf8dc240c5c594d5166b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](https://github.com/dpaluy/rubric_llm/actions/workflows/ci.yml)
|
|
7
7
|
[](https://github.com/dpaluy/rubric_llm/wiki)
|
|
8
8
|
|
|
9
|
-
Provider-agnostic evaluation with pluggable metrics, statistical A/B comparison, and test framework integration
|
|
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
|
|
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
|
|
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
|
|
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
|
|
168
|
-
result.mrr # => 0
|
|
169
|
-
result.ndcg # => 0.
|
|
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
|
|
224
|
-
# relevance
|
|
225
|
-
# correctness
|
|
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
|
|
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
|
|
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`
|
|
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)
|
|
351
|
-
- [Why Retrieval Metrics Are Pure Math](https://github.com/dpaluy/rubric_llm/wiki/Why-Retrieval-Metrics-Are-Pure-Math)
|
|
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 << ("=" *
|
|
25
|
-
lines << "Metric A B Delta p-value Sig"
|
|
26
|
-
lines << ("-" *
|
|
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[:
|
|
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[:
|
|
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[:
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
+
return nil if paired_scores.empty?
|
|
57
60
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
matched = groups_a.keys & groups_b.keys
|
|
86
|
+
warn_unmatched(((groups_a.keys | groups_b.keys) - matched).compact)
|
|
83
87
|
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
137
|
+
results
|
|
153
138
|
end
|
|
154
139
|
|
|
155
140
|
def significance_marker(p)
|
data/lib/rubric_llm/judge.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
#{
|
|
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
|
-
|
|
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
|
-
#{
|
|
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 =
|
|
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
|
data/lib/rubric_llm/minitest.rb
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:)
|
data/lib/rubric_llm/report.rb
CHANGED
|
@@ -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:
|
|
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"
|
|
45
|
+
require "csv"
|
|
46
46
|
metrics = all_metric_names
|
|
47
47
|
CSV.open(path, "w") do |csv|
|
|
48
48
|
csv << ["question", "answer", "overall", *metrics]
|
data/lib/rubric_llm/result.rb
CHANGED
data/lib/rubric_llm/rspec.rb
CHANGED
|
@@ -24,7 +24,7 @@ module RubricLLM
|
|
|
24
24
|
attr_reader :threshold, :config, :result
|
|
25
25
|
|
|
26
26
|
def initialize
|
|
27
|
-
@threshold =
|
|
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?
|
|
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
|
data/lib/rubric_llm/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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:
|