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 +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +74 -24
- data/lib/rubric_llm/comparison.rb +81 -96
- data/lib/rubric_llm/config.rb +5 -4
- data/lib/rubric_llm/judge.rb +28 -13
- 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 +1 -1
- 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 +4 -1
- metadata +6 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d32ed4cba47a2fac15973d05bbf531317fa8d80f4c42b277dae5c302884ae57
|
|
4
|
+
data.tar.gz: a4bfbbc318593df77c33d36d41eda2e3d2f1b0cd4d0485fe9b8a61e72ee1519b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
[](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
|
+
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
168
|
-
result.mrr # => 0
|
|
169
|
-
result.ndcg # => 0.
|
|
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
|
|
224
|
-
# relevance
|
|
225
|
-
# correctness
|
|
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
|
|
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
|
|
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`
|
|
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)
|
|
351
|
-
- [Why Retrieval Metrics Are Pure Math](https://github.com/dpaluy/rubric_llm/wiki/Why-Retrieval-Metrics-Are-Pure-Math)
|
|
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)
|
|
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 << ("=" *
|
|
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/config.rb
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
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,
|
|
@@ -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.
|
|
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
|
-
|
|
47
|
-
validate_response!(content.is_a?(Hash) ? content : parse_json(content))
|
|
60
|
+
validate_response!(parse_json(response.content))
|
|
48
61
|
rescue StandardError => e
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
|
@@ -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
|
|
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
|
+
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:
|
|
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:
|
|
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:
|