rubric_llm 0.1.1 → 0.2.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: 52a6704521634882ccc1d1779c20e2b65587f398b2cfd00b3c7892d9a97706e1
4
- data.tar.gz: 3410606f13f439af21457ac9714b0e67a33752a3231854ccd0f73e759d4ac209
3
+ metadata.gz: 6ed5a9f9ece0c78f86b4134211e39d8100e046046686ec296697d794cefcce15
4
+ data.tar.gz: 40e120b95e7d8b5711e495f909e357395fd5ffbae76cb49dd8aa6c5212488196
5
5
  SHA512:
6
- metadata.gz: 17a9e4d4308627889576b29fbbdb91a93a12e702847780e21fe070cb499ac06d243fa5e688d520e4fd43827eb05ba86b85eafb039598160e31e273838ab69f1c
7
- data.tar.gz: 86b21112726b41c2eef29cc2e0fc0f6b6b4d742a6c2797d46f391d0292fb2ad8477d3792029d1b90f562425118f52df496e998f6d434128c4b4017756aaa217d
6
+ metadata.gz: e07a90dabde33a66007ce21bd2173c577ce61fb1a964ce6946f2df26d03ff29dac18bc5757d209fcf5f8d76b5e8bb72c33562f250ee2335adc9aa1c83b0aada1
7
+ data.tar.gz: 8fcf6d081679021ac44aa729f63e243aabe344aa93c730a5b660d4fcc981963a08f7017276416b68b7b8b2d390756c537469263cdad92772e5f23a66199f1b0e
data/CHANGELOG.md CHANGED
@@ -5,7 +5,21 @@ 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
- ## [Unreleased]
8
+ ## [0.2.0] - 2026-07-11
9
+
10
+ ### Changed
11
+
12
+ - Enforce schema-backed judge responses through RubyLLM structured output when supported
13
+ - Raise `RubricLLM::JudgeError` for empty, malformed, missing-score, non-numeric, or out-of-range judge responses instead of returning `nil` or clamping invalid scores
14
+ - Require `ruby_llm ~> 1.13` for named schema payload support in structured output
15
+ - Record judge failures per metric in `RubricLLM.evaluate` and `RubricLLM.evaluate_batch` as a `nil` score with the error message in details and continue the run, while non-judge errors propagate
16
+ - Remove dead score clamping and nil-response guards from LLM metrics now that the judge validates the response contract
17
+
18
+ ## [0.1.2] - 2026-04-30
19
+
20
+ ### Added
21
+
22
+ - Add standalone LLM-as-Judge examples for RAG scoring, batch reports, model comparison, custom metrics, and Minitest assertions
9
23
 
10
24
  ## [0.1.1] - 2026-03-24
11
25
 
data/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # RubricLLM
2
2
 
3
- Lightweight LLM evaluation framework for Ruby, inspired by [Ragas](https://github.com/vibrantlabsai/ragas), powered by [RubyLLM](https://github.com/crmne/ruby_llm).
3
+ Lightweight LLM evaluation framework for Ruby, inspired by [DeepEval](https://github.com/confident-ai/deepeval), powered by [RubyLLM](https://github.com/crmne/ruby_llm).
4
4
 
5
5
  [![Gem Version](https://badge.fury.io/rb/rubric_llm.svg)](https://badge.fury.io/rb/rubric_llm)
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
+ [![Wiki](https://img.shields.io/badge/docs-wiki-blue)](https://github.com/dpaluy/rubric_llm/wiki)
7
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
10
 
@@ -27,31 +28,34 @@ gem install rubric_llm
27
28
  require "rubric_llm"
28
29
 
29
30
  RubricLLM.configure do |c|
30
- c.judge_model = "gpt-4o"
31
+ c.judge_model = "gpt-5.5"
31
32
  c.judge_provider = :openai
32
33
  end
33
34
 
34
35
  result = RubricLLM.evaluate(
35
- question: "What is the capital of France?",
36
- answer: "The capital of France is Paris, located on the Seine river.",
37
- context: ["Paris is the capital and largest city of France."],
38
- ground_truth: "Paris"
36
+ question: "What is Ruby's core design philosophy?",
37
+ answer: "Ruby was designed by Yukihiro Matsumoto to optimize for developer happiness and productivity, prioritizing the programmer's joy over machine efficiency.",
38
+ context: [
39
+ "Yukihiro Matsumoto, Ruby's creator, has stated that Ruby is designed to make programmers happy. " \
40
+ "He optimized the language for human readability and developer productivity rather than raw machine performance."
41
+ ],
42
+ ground_truth: "Ruby is designed to maximize developer happiness and productivity."
39
43
  )
40
44
 
41
- result.faithfulness # => 0.95
42
- result.relevance # => 0.92
43
- result.correctness # => 0.98
44
- result.overall # => 0.94
45
+ result.correctness # => 0.99
45
46
  result.pass? # => true
46
47
  ```
47
48
 
49
+ For runnable LLM-as-Judge examples, including RAG scoring, batch pass/fail output, model comparison, custom metrics, and live
50
+ Minitest assertions, see [examples/README.md](examples/README.md).
51
+
48
52
  ## Configuration
49
53
 
50
54
  ### Global
51
55
 
52
56
  ```ruby
53
57
  RubricLLM.configure do |c|
54
- c.judge_model = "gpt-4o" # any model RubyLLM supports
58
+ c.judge_model = "gpt-5.5" # any model RubyLLM supports
55
59
  c.judge_provider = :openai # :openai, :anthropic, :gemini, etc.
56
60
  c.temperature = 0.0 # deterministic scoring (default)
57
61
  c.max_tokens = 4096 # max tokens for judge response
@@ -80,7 +84,7 @@ config = RubricLLM::Config.from_env
80
84
  ### Per-Evaluation Override
81
85
 
82
86
  ```ruby
83
- custom = RubricLLM::Config.new(judge_model: "claude-haiku-4-5", judge_provider: :anthropic)
87
+ custom = RubricLLM::Config.new(judge_model: "claude-opus-4-5", judge_provider: :anthropic)
84
88
 
85
89
  result = RubricLLM.evaluate(question: "...", answer: "...", config: custom)
86
90
  report = RubricLLM.evaluate_batch(dataset, config: custom)
@@ -91,7 +95,7 @@ report = RubricLLM.evaluate_batch(dataset, config: custom)
91
95
  ```ruby
92
96
  # config/initializers/rubric_llm.rb
93
97
  RubricLLM.configure do |c|
94
- c.judge_model = "gpt-4o"
98
+ c.judge_model = "gpt-5.5"
95
99
  c.judge_provider = :openai
96
100
  end
97
101
  ```
@@ -104,12 +108,12 @@ These metrics use a judge LLM to evaluate quality. Each sends a structured promp
104
108
 
105
109
  | Metric | Question it answers | Requires |
106
110
  |--------|-------------------|----------|
107
- | **Faithfulness** | Is every claim in the answer supported by the context? | `context` |
108
- | **Relevance** | Does the answer address what was asked? | `question` |
109
111
  | **Correctness** | Does the answer match the known correct answer? | `ground_truth` |
112
+ | **Relevance** | Does the answer address what was asked? | `question` |
110
113
  | **Context Precision** | Are the retrieved context chunks actually relevant? | `question`, `context` |
111
- | **Context Recall** | Do the contexts cover the information in the ground truth? | `context`, `ground_truth` |
112
114
  | **Factual Accuracy** | Are there factual discrepancies between candidate and reference? | `ground_truth` |
115
+ | **Context Recall** | Do the contexts cover the information in the ground truth? | `context`, `ground_truth` |
116
+ | **Faithfulness** | Is every claim in the answer supported by the context? | `context` |
113
117
 
114
118
  ```ruby
115
119
  # Only context — gets faithfulness, relevance, context_precision
@@ -160,8 +164,8 @@ result = RubricLLM.evaluate_retrieval(
160
164
  )
161
165
 
162
166
  result.precision_at_k(3) # => 0.67
163
- result.recall_at_k(3) # => 1.0
164
- result.mrr # => 1.0
167
+ result.recall_at_k(3) # => 0.90
168
+ result.mrr # => 0.90
165
169
  result.ndcg # => 0.86
166
170
  result.hit_rate # => 1.0
167
171
  ```
@@ -203,7 +207,7 @@ report.to_json # returns JSON string
203
207
  Compare two models with statistical significance testing:
204
208
 
205
209
  ```ruby
206
- config_a = RubricLLM::Config.new(judge_model: "gpt-4o")
210
+ config_a = RubricLLM::Config.new(judge_model: "gpt-5.5")
207
211
  config_b = RubricLLM::Config.new(judge_model: "claude-sonnet-4-6")
208
212
 
209
213
  report_a = RubricLLM.evaluate_batch(dataset, config: config_a)
@@ -226,6 +230,8 @@ comparison.significant_regressions # => []
226
230
 
227
231
  Significance markers: `*` (p < 0.05), `**` (p < 0.01), `***` (p < 0.001)
228
232
 
233
+ 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
+
229
235
  ## Test Integration
230
236
 
231
237
  ### Minitest
@@ -315,8 +321,8 @@ RubricLLM uses LLM-as-Judge — an LLM scores another LLM's output. This is the
315
321
 
316
322
  Mitigations built into the framework:
317
323
 
318
- - **Cross-model judging.** Configure a different model as judge than the one being evaluated. Don't let GPT-4o grade GPT-4o.
319
- - **Retrieval metrics are pure math.** `precision_at_k`, `recall_at_k`, `mrr`, `ndcg` — no LLM involved, no judge bias.
324
+ - **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).
320
326
  - **Custom non-LLM metrics.** Subclass `Metrics::Base` with regex checks, embedding similarity, or any deterministic logic.
321
327
  - **Statistical comparison.** A/B testing with paired t-tests surfaces systematic judge bias across runs.
322
328
 
@@ -337,6 +343,13 @@ Ruby has two LLM evaluation options today. Neither fits most use cases:
337
343
  | **Pluggable metrics** | No (fixed set) | Yes | Yes |
338
344
  | **Retrieval metrics** | Yes | No | Yes |
339
345
 
346
+ ## Further Reading
347
+
348
+ Deep dives live in the [project wiki](https://github.com/dpaluy/rubric_llm/wiki):
349
+
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
352
+
340
353
  ## Requirements
341
354
 
342
355
  - Ruby >= 3.4
@@ -350,3 +363,5 @@ Bug reports and pull requests are welcome on [GitHub](https://github.com/dpaluy/
350
363
  ## License
351
364
 
352
365
  [MIT](LICENSE.txt)
366
+
367
+ Supported by [Majestic Labs](https://majesticlabs.dev/).
@@ -30,7 +30,7 @@ module RubricLLM
30
30
  result = metric.call(question:, answer:, context:, ground_truth:)
31
31
  scores[name] = result[:score]
32
32
  details[name] = result[:details]
33
- rescue StandardError => e
33
+ rescue JudgeError => e
34
34
  scores[name] = nil
35
35
  details[name] = { error: e.message }
36
36
  end
@@ -4,6 +4,24 @@ require "json"
4
4
 
5
5
  module RubricLLM
6
6
  class Judge
7
+ METRIC_RESPONSE_SCHEMA = {
8
+ name: "rubric_llm_metric_response",
9
+ strict: false,
10
+ schema: {
11
+ type: "object",
12
+ properties: {
13
+ score: { type: "number", minimum: 0.0, maximum: 1.0 },
14
+ reasoning: { type: "string" },
15
+ claims: { type: "array", items: { type: "object" } },
16
+ context_scores: { type: "array", items: { type: "object" } },
17
+ covered_facts: { type: "array", items: { type: "object" } },
18
+ discrepancies: { type: "array", items: { type: "object" } }
19
+ },
20
+ required: ["score"],
21
+ additionalProperties: true
22
+ }
23
+ }.freeze
24
+
7
25
  attr_reader :config
8
26
 
9
27
  def initialize(config:)
@@ -20,13 +38,19 @@ module RubricLLM
20
38
  chat = RubyLLM.chat(model: config.judge_model, provider: config.judge_provider)
21
39
  chat.with_temperature(config.temperature)
22
40
  chat.with_params(max_tokens: config.max_tokens)
41
+ apply_response_schema(chat)
23
42
 
24
43
  full_system_prompt = build_system_prompt(system_prompt)
25
44
  chat.with_instructions(full_system_prompt)
26
45
  response = chat.ask(user_prompt)
27
- parse_json(response.content)
46
+ content = response.content
47
+ validate_response!(content.is_a?(Hash) ? content : parse_json(content))
28
48
  rescue StandardError => e
29
- raise JudgeError, "Judge call failed: #{e.message}" if attempts > config.max_retries
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
30
54
 
31
55
  sleep(config.retry_base_delay * (2**(attempts - 1)))
32
56
  retry
@@ -36,25 +60,55 @@ module RubricLLM
36
60
  # Parse JSON from LLM output with multiple strategies:
37
61
  # 1. Direct JSON.parse
38
62
  # 2. Extract from markdown code fence
39
- # 3. Return nil (never raises)
63
+ # 3. Raise JudgeError for malformed output
40
64
  def parse_json(text)
41
- return nil if text.nil? || text.strip.empty?
65
+ raise JudgeError, "Judge response was empty" if text.nil? || text.strip.empty?
42
66
 
43
- # Try direct parse
44
67
  JSON.parse(text)
45
- rescue JSON::ParserError
46
- # Try extracting from code fence
68
+ rescue JSON::ParserError => e
47
69
  if (match = text.match(/```(?:json)?\s*\n?(.*?)\n?\s*```/m))
48
70
  begin
49
- JSON.parse(match[1])
50
- rescue JSON::ParserError
51
- nil
71
+ return JSON.parse(match[1])
72
+ rescue JSON::ParserError => e
73
+ raise JudgeError, "Judge response code fence was not valid JSON: #{e.message}"
52
74
  end
53
75
  end
76
+
77
+ raise JudgeError, "Judge response was not valid JSON: #{e.message}"
54
78
  end
55
79
 
56
80
  private
57
81
 
82
+ def apply_response_schema(chat)
83
+ return chat unless chat.respond_to?(:with_schema)
84
+ return chat unless structured_output_supported?(chat)
85
+
86
+ chat.with_schema(METRIC_RESPONSE_SCHEMA)
87
+ end
88
+
89
+ def structured_output_supported?(chat)
90
+ return true unless chat.respond_to?(:model)
91
+ return true unless chat.model.respond_to?(:structured_output?)
92
+
93
+ chat.model.structured_output?
94
+ end
95
+
96
+ def validate_response!(response)
97
+ raise JudgeError, "Judge response must be a JSON object" unless response.is_a?(Hash)
98
+ raise JudgeError, "Judge response missing required score" unless response.key?("score")
99
+
100
+ score = parse_score(response["score"])
101
+ raise JudgeError, "Judge response score must be between 0.0 and 1.0" unless score.finite? && score.between?(0.0, 1.0)
102
+
103
+ response
104
+ end
105
+
106
+ def parse_score(score)
107
+ Float(score)
108
+ rescue ArgumentError, TypeError
109
+ raise JudgeError, "Judge response score must be numeric"
110
+ end
111
+
58
112
  def build_system_prompt(base_prompt)
59
113
  return base_prompt unless config.custom_prompt
60
114
 
@@ -19,10 +19,7 @@ module RubricLLM
19
19
  private
20
20
 
21
21
  def judge_eval(system_prompt:, user_prompt:)
22
- result = judge.call(system_prompt:, user_prompt:)
23
- return { score: nil, details: { error: "No response from judge" } } if result.nil?
24
-
25
- result
22
+ judge.call(system_prompt:, user_prompt:)
26
23
  end
27
24
  end
28
25
  end
@@ -34,10 +34,8 @@ module RubricLLM
34
34
  private
35
35
 
36
36
  def normalize(result)
37
- return { score: nil, details: result } unless result.is_a?(Hash) && result["score"]
38
-
39
37
  {
40
- score: Float(result["score"]).clamp(0.0, 1.0),
38
+ score: Float(result["score"]),
41
39
  details: {
42
40
  context_scores: result["context_scores"],
43
41
  reasoning: result["reasoning"]
@@ -35,10 +35,8 @@ module RubricLLM
35
35
  private
36
36
 
37
37
  def normalize(result)
38
- return { score: nil, details: result } unless result.is_a?(Hash) && result["score"]
39
-
40
38
  {
41
- score: Float(result["score"]).clamp(0.0, 1.0),
39
+ score: Float(result["score"]),
42
40
  details: {
43
41
  covered_facts: result["covered_facts"],
44
42
  reasoning: result["reasoning"]
@@ -34,10 +34,8 @@ module RubricLLM
34
34
  private
35
35
 
36
36
  def normalize(result)
37
- return { score: nil, details: result } unless result.is_a?(Hash) && result["score"]
38
-
39
37
  {
40
- score: Float(result["score"]).clamp(0.0, 1.0),
38
+ score: Float(result["score"]),
41
39
  details: { reasoning: result["reasoning"] }
42
40
  }
43
41
  end
@@ -33,10 +33,8 @@ module RubricLLM
33
33
  private
34
34
 
35
35
  def normalize(result)
36
- return { score: nil, details: result } unless result.is_a?(Hash) && result["score"]
37
-
38
36
  {
39
- score: Float(result["score"]).clamp(0.0, 1.0),
37
+ score: Float(result["score"]),
40
38
  details: {
41
39
  discrepancies: result["discrepancies"],
42
40
  reasoning: result["reasoning"]
@@ -36,10 +36,8 @@ module RubricLLM
36
36
  private
37
37
 
38
38
  def normalize(result)
39
- return { score: nil, details: result } unless result.is_a?(Hash) && result["score"]
40
-
41
39
  {
42
- score: Float(result["score"]).clamp(0.0, 1.0),
40
+ score: Float(result["score"]),
43
41
  details: {
44
42
  claims: result["claims"],
45
43
  reasoning: result["reasoning"]
@@ -30,10 +30,8 @@ module RubricLLM
30
30
  private
31
31
 
32
32
  def normalize(result)
33
- return { score: nil, details: result } unless result.is_a?(Hash) && result["score"]
34
-
35
33
  {
36
- score: Float(result["score"]).clamp(0.0, 1.0),
34
+ score: Float(result["score"]),
37
35
  details: { reasoning: result["reasoning"] }
38
36
  }
39
37
  end
@@ -51,7 +51,7 @@ module RubricLLM
51
51
  end
52
52
 
53
53
  def hit_rate
54
- retrieved.any? { |doc| relevant.include?(doc) } ? 1.0 : 0.0
54
+ relevant.intersect?(retrieved) ? 1.0 : 0.0
55
55
  end
56
56
 
57
57
  def to_h
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubricLLM
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Paluy
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '1.0'
18
+ version: '1.13'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '1.0'
25
+ version: '1.13'
26
26
  description: Provider-agnostic LLM evaluation with pluggable metrics, statistical
27
27
  A/B comparison, and test framework integration. Ragas for Ruby, powered by RubyLLM.
28
28
  email: