rubric_llm 0.1.2 → 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: 4984077f320263925eed14be810301406ff2a6f369f8d3d50ed7678e8d997c9c
4
- data.tar.gz: 9457b67015d20bd1e68d2b0f4acfaa836c6842b0a0b567d6393911cdcb012fd4
3
+ metadata.gz: 6ed5a9f9ece0c78f86b4134211e39d8100e046046686ec296697d794cefcce15
4
+ data.tar.gz: 40e120b95e7d8b5711e495f909e357395fd5ffbae76cb49dd8aa6c5212488196
5
5
  SHA512:
6
- metadata.gz: 58e6b00870262214c6a7a1331987e22e7f496490f3b3bd135b4f56b400718d7bfa28727749412b58687b0dc5c16c33d3dfa59fc20383a314fc58b031083232e1
7
- data.tar.gz: 7c6c68ffb6ec620c8f02f7bde4993c90eaf707c72057f77d240072108aba267e77b9f3b44618c9b6d8a80d70aaed40b21c84f795ec4a8c8228c08d16ac7f49e9
6
+ metadata.gz: e07a90dabde33a66007ce21bd2173c577ce61fb1a964ce6946f2df26d03ff29dac18bc5757d209fcf5f8d76b5e8bb72c33562f250ee2335adc9aa1c83b0aada1
7
+ data.tar.gz: 8fcf6d081679021ac44aa729f63e243aabe344aa93c730a5b660d4fcc981963a08f7017276416b68b7b8b2d390756c537469263cdad92772e5f23a66199f1b0e
data/CHANGELOG.md CHANGED
@@ -5,7 +5,15 @@ 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
9
17
 
10
18
  ## [0.1.2] - 2026-04-30
11
19
 
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
 
@@ -32,16 +33,16 @@ RubricLLM.configure do |c|
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
 
@@ -54,7 +55,7 @@ Minitest assertions, see [examples/README.md](examples/README.md).
54
55
 
55
56
  ```ruby
56
57
  RubricLLM.configure do |c|
57
- c.judge_model = "gpt-4o" # any model RubyLLM supports
58
+ c.judge_model = "gpt-5.5" # any model RubyLLM supports
58
59
  c.judge_provider = :openai # :openai, :anthropic, :gemini, etc.
59
60
  c.temperature = 0.0 # deterministic scoring (default)
60
61
  c.max_tokens = 4096 # max tokens for judge response
@@ -83,7 +84,7 @@ config = RubricLLM::Config.from_env
83
84
  ### Per-Evaluation Override
84
85
 
85
86
  ```ruby
86
- 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)
87
88
 
88
89
  result = RubricLLM.evaluate(question: "...", answer: "...", config: custom)
89
90
  report = RubricLLM.evaluate_batch(dataset, config: custom)
@@ -94,7 +95,7 @@ report = RubricLLM.evaluate_batch(dataset, config: custom)
94
95
  ```ruby
95
96
  # config/initializers/rubric_llm.rb
96
97
  RubricLLM.configure do |c|
97
- c.judge_model = "gpt-4o"
98
+ c.judge_model = "gpt-5.5"
98
99
  c.judge_provider = :openai
99
100
  end
100
101
  ```
@@ -107,12 +108,12 @@ These metrics use a judge LLM to evaluate quality. Each sends a structured promp
107
108
 
108
109
  | Metric | Question it answers | Requires |
109
110
  |--------|-------------------|----------|
110
- | **Faithfulness** | Is every claim in the answer supported by the context? | `context` |
111
- | **Relevance** | Does the answer address what was asked? | `question` |
112
111
  | **Correctness** | Does the answer match the known correct answer? | `ground_truth` |
112
+ | **Relevance** | Does the answer address what was asked? | `question` |
113
113
  | **Context Precision** | Are the retrieved context chunks actually relevant? | `question`, `context` |
114
- | **Context Recall** | Do the contexts cover the information in the ground truth? | `context`, `ground_truth` |
115
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` |
116
117
 
117
118
  ```ruby
118
119
  # Only context — gets faithfulness, relevance, context_precision
@@ -163,8 +164,8 @@ result = RubricLLM.evaluate_retrieval(
163
164
  )
164
165
 
165
166
  result.precision_at_k(3) # => 0.67
166
- result.recall_at_k(3) # => 1.0
167
- result.mrr # => 1.0
167
+ result.recall_at_k(3) # => 0.90
168
+ result.mrr # => 0.90
168
169
  result.ndcg # => 0.86
169
170
  result.hit_rate # => 1.0
170
171
  ```
@@ -206,7 +207,7 @@ report.to_json # returns JSON string
206
207
  Compare two models with statistical significance testing:
207
208
 
208
209
  ```ruby
209
- config_a = RubricLLM::Config.new(judge_model: "gpt-4o")
210
+ config_a = RubricLLM::Config.new(judge_model: "gpt-5.5")
210
211
  config_b = RubricLLM::Config.new(judge_model: "claude-sonnet-4-6")
211
212
 
212
213
  report_a = RubricLLM.evaluate_batch(dataset, config: config_a)
@@ -229,6 +230,8 @@ comparison.significant_regressions # => []
229
230
 
230
231
  Significance markers: `*` (p < 0.05), `**` (p < 0.01), `***` (p < 0.001)
231
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
+
232
235
  ## Test Integration
233
236
 
234
237
  ### Minitest
@@ -318,8 +321,8 @@ RubricLLM uses LLM-as-Judge — an LLM scores another LLM's output. This is the
318
321
 
319
322
  Mitigations built into the framework:
320
323
 
321
- - **Cross-model judging.** Configure a different model as judge than the one being evaluated. Don't let GPT-4o grade GPT-4o.
322
- - **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).
323
326
  - **Custom non-LLM metrics.** Subclass `Metrics::Base` with regex checks, embedding similarity, or any deterministic logic.
324
327
  - **Statistical comparison.** A/B testing with paired t-tests surfaces systematic judge bias across runs.
325
328
 
@@ -340,6 +343,13 @@ Ruby has two LLM evaluation options today. Neither fits most use cases:
340
343
  | **Pluggable metrics** | No (fixed set) | Yes | Yes |
341
344
  | **Retrieval metrics** | Yes | No | Yes |
342
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
+
343
353
  ## Requirements
344
354
 
345
355
  - Ruby >= 3.4
@@ -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.2"
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.2
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: