rubric_llm 0.7.0 → 0.9.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: 3f75b6e37c79f540f62929562809323154ab17055a6e1f6b74c918605dd3f4c0
4
- data.tar.gz: 9a4a7752bf87c1e16e4d75ff190d5a45f9c368276cb47a7c148be2c873de9e35
3
+ metadata.gz: 73d2f6cf20d68d6f47f15d8f92e9ff7ce8efcd2890bec070037f76879dc022c5
4
+ data.tar.gz: 0d59825beab623fc60f8a4c62a132bce8ab1b6b940364a58f02234bb08e2c546
5
5
  SHA512:
6
- metadata.gz: 3606120936454d38b72f5441ef3329a645a3dd725148728fe3e3b45e595c76028d02aeab176767ec93bacae330709ec9ad1bdff57f06ac4942ddda89a518ad5b
7
- data.tar.gz: 667739e0ee4e4568faf3f438b9b3b4455fcd9d7fb0815e2575f172d8e91abb33c7a03fe9f4b436f14cc0a35614c9ce604d23c1d509befaf62e0a023b81aa107c
6
+ metadata.gz: 8d0e52051db16a2b845fb865d6ab88e43521165752937df2eeb6823dc62e8e0e860516e2d2d2c85f9ff37c414268ac8720516cb2dd9db2dd5911a97577b22552
7
+ data.tar.gz: 3b904616349d102198a71be1188766aedb8c80adce7e8ac45e8ed616f02c0a921bd5c02980a6f49fc37aa12a6819d4acb019db9c557701b8f413980a34c6fa0b
data/CHANGELOG.md CHANGED
@@ -5,6 +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
+ ## [0.9.0] - 2026-10-14
9
+
10
+ ### Changed
11
+
12
+ - Require RubyLLM `~> 2.1`, `>= 2.1.1` (>= 2.1.1, < 3.0), excluding 2.1.0's fresh-install Faraday dependency resolution defect; Ruby >= 3.4 remains supported
13
+ - Verify RubyLLM 2.1's shared transport connections with offline real-client contracts: outer judge retries retain their HTTP attempt budgets, while independent judge requests keep credentials and conversation history separate
14
+
15
+ ## [0.8.0] - 2026-09-22
16
+
17
+ ### Changed
18
+
19
+ - Calculate faithfulness, context precision, and context recall scores from the judge's item-level decisions instead of its suggested score
20
+ - Define scoring criteria for correctness, relevance, and factual accuracy, with factual accuracy measuring contradictions against the reference rather than missing facts
21
+ - Reject missing or malformed metric details, including missing reasoning, empty claim or fact lists, invalid context indices, and factual accuracy scores that conflict with their discrepancy lists. Context indices must be 1-based integers. Evaluation returns `nil` with an error for these responses; direct metric calls raise `JudgeError`
22
+
8
23
  ## [0.7.0] - 2026-09-22
9
24
 
10
25
  ### Added
data/README.md CHANGED
@@ -8,15 +8,15 @@ Lightweight LLM evaluation framework for Ruby, inspired by [DeepEval](https://gi
8
8
 
9
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
- RubricLLM `0.7.0` supports RubyLLM `~> 2.0` and Ruby 3.4 or later. Applications that use RubyLLM 1.x should stay on RubricLLM `0.5.x`.
11
+ RubricLLM 0.9.0 requires RubyLLM `~> 2.1`, `>= 2.1.1` (>= 2.1.1, < 3.0) and Ruby 3.4 or later. Applications that use RubyLLM 1.x should stay on RubricLLM `0.5.x`.
12
12
 
13
13
  ## Installation
14
14
 
15
15
  Update both gem constraints together:
16
16
 
17
17
  ```ruby
18
- gem "rubric_llm", "~> 0.7.0"
19
- gem "ruby_llm", "~> 2.0"
18
+ gem "rubric_llm", "~> 0.9.0"
19
+ gem "ruby_llm", "~> 2.1", ">= 2.1.1"
20
20
  ```
21
21
 
22
22
  Then resolve both gems together:
@@ -25,12 +25,14 @@ Then resolve both gems together:
25
25
  bundle update rubric_llm ruby_llm
26
26
  ```
27
27
 
28
- If the application still has a RubyLLM 1.x constraint, update both constraints before running Bundler.
28
+ If the application still has a RubyLLM 1.x constraint, update both constraints before running Bundler. RubyLLM 2.1.1 or later is required: it fixes a fresh-install Faraday dependency resolution problem in 2.1.0.
29
+
30
+ RubyLLM 2.1's `RubyLLM::Judge` and `RubyLLM::Evaluation` are separate upstream APIs. RubricLLM keeps its own judge, metrics, and reports; this upgrade does not switch evaluation engines.
29
31
 
30
32
  Or install directly:
31
33
 
32
34
  ```bash
33
- gem install rubric_llm --version 0.7.0
35
+ gem install rubric_llm --version 0.9.0
34
36
  ```
35
37
 
36
38
  ## Quick Start
@@ -90,7 +92,7 @@ RubyLLM.configure do |config|
90
92
  end
91
93
  ```
92
94
 
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/).
95
+ 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 [upgrade guide](https://rubyllm.com/upgrading/), [request control guide](https://rubyllm.com/chat-request-control/), and [structured output support](https://rubyllm.com/structured-output/).
94
96
 
95
97
  ### Judge Thinking Effort
96
98
 
@@ -153,11 +155,11 @@ RubricLLM.configure do |c|
153
155
  end
154
156
  ```
155
157
 
156
- 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.
158
+ RubricLLM has no Rails models or database migrations. If the application also uses RubyLLM's Rails persistence, follow RubyLLM's [upgrade guide](https://rubyllm.com/upgrading/) and run its migrations separately.
157
159
 
158
160
  ### Retries
159
161
 
160
- 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.
162
+ 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 does not combine or redesign these retry layers. RubyLLM 2.1 reuses transport connections for matching settings, including across RubricLLM's outer retries; this does not change the HTTP attempt budgets or share judge conversation history.
161
163
 
162
164
  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.
163
165
 
@@ -165,14 +167,14 @@ RubyLLM classifies OpenAI's HTTP 429 `insufficient_quota` response as a rate-lim
165
167
 
166
168
  ### LLM-as-Judge Metrics
167
169
 
168
- These metrics use a judge LLM to evaluate quality. Each sends a structured prompt and parses a JSON response with a 0.0–1.0 score.
170
+ These metrics use a judge LLM to evaluate quality. Each returns a 0.0–1.0 score. Faithfulness counts supported answer claims, context precision counts relevant non-empty chunks, and context recall counts covered reference facts. These three scores are calculated from the judge's item-level decisions, not its suggested score. An empty or malformed item list (including an answer with no factual claims) is an evaluation error, not a quality score. Correctness, relevance, and factual accuracy use judge scores with metric-specific scoring criteria. The judge still decides what counts as a claim, fact, or relevant chunk, so scores are not deterministic across models.
169
171
 
170
172
  | Metric | Question it answers | Requires |
171
173
  |--------|-------------------|----------|
172
174
  | **Correctness** | Does the answer match the known correct answer? | `ground_truth` |
173
175
  | **Relevance** | Does the answer address what was asked? | `question` |
174
176
  | **Context Precision** | Are the retrieved context chunks actually relevant? | `question`, `context` |
175
- | **Factual Accuracy** | Are there factual discrepancies between candidate and reference? | `ground_truth` |
177
+ | **Factual Accuracy** | Does the candidate contradict the reference (not omit it)? | `ground_truth` |
176
178
  | **Context Recall** | Do the contexts cover the information in the ground truth? | `context`, `ground_truth` |
177
179
  | **Faithfulness** | Is every claim in the answer supported by the context? | `context` |
178
180
 
@@ -426,7 +428,7 @@ Deep dives live in the [project wiki](https://github.com/dpaluy/rubric_llm/wiki)
426
428
  ## Requirements
427
429
 
428
430
  - Ruby >= 3.4
429
- - [ruby_llm](https://github.com/crmne/ruby_llm) ~> 2.0 for RubricLLM 0.7.0
431
+ - [ruby_llm](https://github.com/crmne/ruby_llm) >= 2.1.1, < 3.0 for the current development version
430
432
  - An API key for your chosen LLM provider (set via RubyLLM configuration)
431
433
 
432
434
  ## Contributing
@@ -35,6 +35,29 @@ module RubricLLM
35
35
  def judge_eval(system_prompt:, user_prompt:)
36
36
  judge.call(system_prompt:, user_prompt:)
37
37
  end
38
+
39
+ def checked_items(result, key, label:, value_key:, text_key:, count: nil)
40
+ items = result[key]
41
+ valid = items.is_a?(Array) && items.any? && (count.nil? || items.size == count)
42
+ valid &&= items.all? do |item|
43
+ item.is_a?(Hash) && item[text_key].is_a?(String) && !item[text_key].strip.empty? &&
44
+ [true, false].include?(item[value_key])
45
+ end
46
+ raise JudgeError, "Judge response has invalid #{label}" unless valid
47
+
48
+ items
49
+ end
50
+
51
+ def fraction(items, key)
52
+ items.count { |item| item[key] }.to_f / items.size
53
+ end
54
+
55
+ def reasoning_for(result)
56
+ reasoning = result["reasoning"]
57
+ raise JudgeError, "Judge response missing reasoning" unless reasoning.is_a?(String) && !reasoning.strip.empty?
58
+
59
+ reasoning
60
+ end
38
61
  end
39
62
  end
40
63
  end
@@ -5,7 +5,8 @@ module RubricLLM
5
5
  class ContextPrecision < Base
6
6
  SYSTEM_PROMPT = <<~PROMPT
7
7
  You are an evaluation judge. Assess whether the retrieved contexts are relevant to the question.
8
- Context precision measures if the retrieved documents are useful for answering the question.
8
+ Context precision is the fraction of retrieved chunks useful for answering the question.
9
+ Classify every numbered chunk once, in order. Use the displayed 1-based index.
9
10
 
10
11
  Respond with JSON only:
11
12
  {
@@ -29,18 +30,20 @@ module RubricLLM
29
30
  PROMPT
30
31
 
31
32
  result = judge_eval(system_prompt: SYSTEM_PROMPT, user_prompt:)
32
- normalize(result)
33
+ normalize(result, context_chunks.size)
33
34
  end
34
35
 
35
36
  private
36
37
 
37
- def normalize(result)
38
+ def normalize(result, count)
39
+ context_scores = checked_items(result, "context_scores", label: "context scores", count:, value_key: "relevant", text_key: "reason")
40
+ unless context_scores.each_with_index.all? { |item, index| item["index"].is_a?(Integer) && item["index"] == index + 1 }
41
+ raise JudgeError, "Judge response has invalid context indices"
42
+ end
43
+
38
44
  {
39
- score: Float(result["score"]),
40
- details: {
41
- context_scores: result["context_scores"],
42
- reasoning: result["reasoning"]
43
- }
45
+ score: fraction(context_scores, "relevant"),
46
+ details: { context_scores:, reasoning: reasoning_for(result) }
44
47
  }
45
48
  end
46
49
  end
@@ -5,7 +5,10 @@ module RubricLLM
5
5
  class ContextRecall < Base
6
6
  SYSTEM_PROMPT = <<~PROMPT
7
7
  You are an evaluation judge. Assess whether the provided contexts cover the information in the ground truth.
8
- Context recall measures if the retrieved documents contain enough information to construct the ground truth answer.
8
+ Context recall is the fraction of facts in the ground truth supported by the contexts.
9
+ List every distinct factual claim in the ground truth. Mark it covered only if a numbered
10
+ context supports it, and use that context's 1-based index as source_context.
11
+ Use null for source_context when a fact is not covered.
9
12
 
10
13
  Respond with JSON only:
11
14
  {
@@ -31,18 +34,22 @@ module RubricLLM
31
34
  PROMPT
32
35
 
33
36
  result = judge_eval(system_prompt: SYSTEM_PROMPT, user_prompt:)
34
- normalize(result)
37
+ normalize(result, context_chunks.size)
35
38
  end
36
39
 
37
40
  private
38
41
 
39
- def normalize(result)
42
+ def normalize(result, count)
43
+ covered_facts = checked_items(result, "covered_facts", label: "covered facts", value_key: "covered", text_key: "fact")
44
+ valid_sources = covered_facts.all? do |item|
45
+ source = item["source_context"]
46
+ item["covered"] ? source.is_a?(Integer) && (1..count).cover?(source) : source.nil?
47
+ end
48
+ raise JudgeError, "Judge response has invalid source contexts" unless valid_sources
49
+
40
50
  {
41
- score: Float(result["score"]),
42
- details: {
43
- covered_facts: result["covered_facts"],
44
- reasoning: result["reasoning"]
45
- }
51
+ score: fraction(covered_facts, "covered"),
52
+ details: { covered_facts:, reasoning: reasoning_for(result) }
46
53
  }
47
54
  end
48
55
  end
@@ -5,7 +5,11 @@ module RubricLLM
5
5
  class Correctness < Base
6
6
  SYSTEM_PROMPT = <<~PROMPT
7
7
  You are an evaluation judge. Assess whether the answer matches the ground truth.
8
- Consider semantic equivalence, not just exact string matching.
8
+ Consider semantic equivalence, not just exact string matching. Score the answer against
9
+ the question and reference: 1.0 if it fully answers with no material errors, 0.5 if it
10
+ is partly correct but misses required information or includes a material error, and 0.0
11
+ if it is wrong or does not answer. Use intermediate values for partial cases and explain
12
+ what is correct, missing, or wrong. Do not require irrelevant reference details.
9
13
 
10
14
  Respond with JSON only:
11
15
  {
@@ -36,7 +40,7 @@ module RubricLLM
36
40
  def normalize(result)
37
41
  {
38
42
  score: Float(result["score"]),
39
- details: { reasoning: result["reasoning"] }
43
+ details: { reasoning: reasoning_for(result) }
40
44
  }
41
45
  end
42
46
  end
@@ -5,7 +5,12 @@ module RubricLLM
5
5
  class FactualAccuracy < Base
6
6
  SYSTEM_PROMPT = <<~PROMPT
7
7
  You are an evaluation judge. Compare the factual claims in the candidate answer against the reference answer.
8
- Identify any discrepancies where the candidate states something different from the reference.
8
+ Identify contradictions in candidate factual claims against the reference. Do not penalize
9
+ missing reference facts, which correctness measures. Score 1.0 if there are no contradictions,
10
+ 0.5 if minor factual contradictions affect part of the answer, and 0.0 if major contradictions
11
+ undermine the answer. Use intermediate values for partial cases. Explain the score and list
12
+ each contradiction with severity minor or major. If the reference does not establish whether
13
+ a claim is true, do not call it a contradiction.
9
14
 
10
15
  Respond with JSON only:
11
16
  {
@@ -33,14 +38,25 @@ module RubricLLM
33
38
  private
34
39
 
35
40
  def normalize(result)
41
+ discrepancies = result["discrepancies"]
42
+ unless discrepancies.is_a?(Array) && discrepancies.all? { |item| valid_discrepancy?(item) }
43
+ raise JudgeError, "Judge response has invalid discrepancies"
44
+ end
45
+
46
+ score = Float(result["score"])
47
+ raise JudgeError, "Judge response score conflicts with no discrepancies" if discrepancies.empty? && score < 1.0
48
+ raise JudgeError, "Judge response score conflicts with discrepancies" if discrepancies.any? && score >= 1.0
49
+
36
50
  {
37
- score: Float(result["score"]),
38
- details: {
39
- discrepancies: result["discrepancies"],
40
- reasoning: result["reasoning"]
41
- }
51
+ score:,
52
+ details: { discrepancies:, reasoning: reasoning_for(result) }
42
53
  }
43
54
  end
55
+
56
+ def valid_discrepancy?(item)
57
+ item.is_a?(Hash) && %w[claim reference].all? { |key| item[key].is_a?(String) && !item[key].strip.empty? } &&
58
+ %w[minor major].include?(item["severity"])
59
+ end
44
60
  end
45
61
  end
46
62
  end
@@ -6,6 +6,8 @@ module RubricLLM
6
6
  SYSTEM_PROMPT = <<~PROMPT
7
7
  You are an evaluation judge. Assess whether the answer is faithful to the provided context.
8
8
  A faithful answer only contains information that is supported by the context.
9
+ List every factual claim in the answer. Mark a claim supported only if the context supports it.
10
+ The score is the fraction of supported claims. If there are no factual claims, do not invent claims.
9
11
 
10
12
  Respond with JSON only:
11
13
  {
@@ -36,11 +38,12 @@ module RubricLLM
36
38
  private
37
39
 
38
40
  def normalize(result)
41
+ claims = checked_items(result, "claims", label: "claims", value_key: "supported", text_key: "claim")
39
42
  {
40
- score: Float(result["score"]),
43
+ score: fraction(claims, "supported"),
41
44
  details: {
42
- claims: result["claims"],
43
- reasoning: result["reasoning"]
45
+ claims:,
46
+ reasoning: reasoning_for(result)
44
47
  }
45
48
  }
46
49
  end
@@ -5,7 +5,10 @@ module RubricLLM
5
5
  class Relevance < Base
6
6
  SYSTEM_PROMPT = <<~PROMPT
7
7
  You are an evaluation judge. Assess whether the answer is relevant to the question.
8
- A relevant answer directly addresses what was asked.
8
+ A relevant answer directly addresses what was asked, regardless of its factual accuracy.
9
+ Score 1.0 if it directly addresses all parts of the question, 0.5 if it addresses only
10
+ part or is mostly tangential, and 0.0 if it does not address the question. Use intermediate
11
+ values for partial cases and explain the score. Do not score correctness here.
9
12
 
10
13
  Respond with JSON only:
11
14
  {
@@ -32,7 +35,7 @@ module RubricLLM
32
35
  def normalize(result)
33
36
  {
34
37
  score: Float(result["score"]),
35
- details: { reasoning: result["reasoning"] }
38
+ details: { reasoning: reasoning_for(result) }
36
39
  }
37
40
  end
38
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RubricLLM
4
- VERSION = "0.7.0"
4
+ VERSION = "0.9.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.7.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Paluy
@@ -29,14 +29,20 @@ dependencies:
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '2.0'
32
+ version: '2.1'
33
+ - - ">="
34
+ - !ruby/object:Gem::Version
35
+ version: 2.1.1
33
36
  type: :runtime
34
37
  prerelease: false
35
38
  version_requirements: !ruby/object:Gem::Requirement
36
39
  requirements:
37
40
  - - "~>"
38
41
  - !ruby/object:Gem::Version
39
- version: '2.0'
42
+ version: '2.1'
43
+ - - ">="
44
+ - !ruby/object:Gem::Version
45
+ version: 2.1.1
40
46
  description: Provider-agnostic LLM evaluation with pluggable metrics, statistical
41
47
  A/B comparison, and test framework integration. Ragas for Ruby, powered by RubyLLM.
42
48
  email: