ruby_llm-llm_judge 0.1.3 → 0.1.4
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 +7 -0
- data/README.md +25 -0
- data/lib/ruby_llm/llm_judge/engine.rb +85 -22
- data/lib/ruby_llm/llm_judge/provider.rb +4 -1
- data/lib/ruby_llm/llm_judge/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fc81f9575b6468ad1a7ea81b19d777ea169eca0619cf1995c59c0aab01128254
|
|
4
|
+
data.tar.gz: 6052e01d0b02b576c25fe5baa0545a8f89e9060e12d42ae65f3fa99e8e14f5f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ba27da7ce65adbb3d39d6447decaa6b2454d2bbee7086577f81798cb213ccf573efd81c250d46a5bc29d7b58d811892a73918b7f89cddd1dec778b50620d8406
|
|
7
|
+
data.tar.gz: 411fd92bb9f7700b6075559ed5644cbdad5485a555fc6601249ebfc1c28d657dbc221255bf308f4358c1730cd3c506b08c553a7e2359ddb0e449a2c0e5aedaf7
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4 (2026-10-03)
|
|
4
|
+
|
|
5
|
+
- `structured_output: true` sends a strict JSON Schema with one-call requests, requiring every question and option.
|
|
6
|
+
- `strategy: :committed` asks for one option per question and returns it with probability 1.
|
|
7
|
+
- The provider accepts RubyLLM main's `with:` attachments argument and raises `UnsupportedAttachmentError` when images are passed. Judgments on RubyLLM main failed with `ArgumentError` before.
|
|
8
|
+
- The README explains when to enable reasoning: off suits classification, while rule-following questions need it. See [the decision-mode benchmarks](benchmarks/decision-modes.md).
|
|
9
|
+
|
|
3
10
|
## 0.1.3 (2026-09-29)
|
|
4
11
|
|
|
5
12
|
- Setting a `chat_provider_options` key to `nil` removes that field, including the Luna defaults and, before RubyLLM 2.1, the output limit field. A nested hash emptied this way is removed too.
|
data/README.md
CHANGED
|
@@ -142,6 +142,31 @@ result = RubyLLM::LLMJudge.judge(
|
|
|
142
142
|
|
|
143
143
|
OpenRouter's [latency sorting](https://openrouter.ai/docs/guides/routing/provider-selection) chooses among its available providers using their observed response times. It sped up OpenRouter in our test, while direct OpenAI was faster still. The measured speed and answer-quality tradeoffs are below.
|
|
144
144
|
|
|
145
|
+
### Strict response schemas
|
|
146
|
+
|
|
147
|
+
Set `structured_output: true` to send a strict JSON Schema with the one-call request, through RubyLLM's `with_schema`. The schema requires every question and every option, and nothing else, so a provider that supports structured outputs cannot return the wrong shape. Without it, the gem validates the reply and makes its corrective retry. In our runs it had little effect on accuracy, cut GPT-6 Luna's corrective retries from 3.3 to 0.7 per 96 judgments, and added 40–350 ms. Models and providers without structured-output support reject the request, so leave it off for them.
|
|
148
|
+
|
|
149
|
+
### Committed answers
|
|
150
|
+
|
|
151
|
+
Set `strategy: :committed` to ask for one option per question instead of a distribution. The reply is shorter, and each answer comes back with probability 1 for the chosen option and 0 for the rest, so `confidence` is always 1.0 and the probabilities carry no calibration. Use it when you only need the decision; use `:single_request` or `:ratings` when you threshold on probabilities. It accepts `structured_output: true`, which limits each answer to that question's option IDs. On classification it was 100–300 ms faster than probabilities and as accurate or up to three cases in 64 less; on rule-following questions it was the most accurate mode with reasoning off.
|
|
152
|
+
|
|
153
|
+
### Reasoning
|
|
154
|
+
|
|
155
|
+
The Luna defaults disable reasoning, which suits classification: on the benchmarks below, Luna is as accurate with reasoning off and much faster. Questions that apply explicit rules to a state are different. On [StreamDecisionBench](https://github.com/JacobLinCool/StreamDecisionBench), whose questions carry decision policies, GPT-5.6 Luna through LLM Judge answered 47% of states correctly with reasoning off and 90% with reasoning effort low, at about twice the latency. The ratings strategy cannot apply a policy across a state and should not be used for these questions. [Classification and rule-following results by mode](benchmarks/decision-modes.md).
|
|
156
|
+
|
|
157
|
+
To enable reasoning, override the default and leave room for it in the output limit:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
provider_options: {
|
|
161
|
+
strategy: :single_request,
|
|
162
|
+
max_output_tokens: 4096,
|
|
163
|
+
chat_provider_options: { reasoning_effort: 'low' } # OpenAI
|
|
164
|
+
# chat_provider_options: { reasoning: { effort: 'low' } } # OpenRouter
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Reasoning does not fit the `:ratings` strategy, whose four-token output limit is meant for a single digit.
|
|
169
|
+
|
|
145
170
|
## How scoring works
|
|
146
171
|
|
|
147
172
|
The default `:ratings` strategy asks the model to rate every declared answer from 0 to 9. It makes one call per answer, with up to six calls running at once (`max_workers:` changes this), then applies softmax to the ratings. If a call fails after its retry, no new calls start, calls in flight finish, and the error is raised. It retries a malformed digit once. When the highest ratings tie on a Choice question, it makes a one-call JSON judgment for that question and uses its distribution. If that call also ties, it raises an error instead of choosing whichever option came first. `result.raw[:tie_breaks]` records each resolution, and token usage includes the extra call. Set `tie_breaker: :first` to use the original first-option rule, or `tie_break_max_output_tokens:` to change the tie-break response limit (default 8192).
|
|
@@ -21,18 +21,23 @@ module RubyLLM
|
|
|
21
21
|
'each question\'s probabilities sum to 1. Evaluate each question independently ' \
|
|
22
22
|
'against the same state. Return no explanations or markdown. The "answers" value ' \
|
|
23
23
|
'must be an object whose keys are the question IDs.'
|
|
24
|
+
COMMITTED_INSTRUCTIONS = 'Answer all questions using only a JSON object with an "answers" field whose keys are ' \
|
|
25
|
+
'the question IDs. For each question, give the ID of the one option that best applies. ' \
|
|
26
|
+
'Evaluate each question independently against the same state. Return no explanations ' \
|
|
27
|
+
'or markdown.'
|
|
28
|
+
STRATEGIES = %i[ratings single_request committed].freeze
|
|
24
29
|
|
|
25
30
|
def initialize(config:, model: DEFAULT_MODEL, provider_options: {}, scorer: nil, responder: nil)
|
|
26
31
|
@config = config
|
|
27
32
|
options = provider_options.transform_keys(&:to_sym)
|
|
28
33
|
allowed = %i[scoring_provider scoring_protocol temperature max_output_tokens chat_provider_options
|
|
29
34
|
max_arms max_input_bytes strategy malformed_retries tie_breaker tie_break_max_output_tokens
|
|
30
|
-
max_workers]
|
|
35
|
+
max_workers structured_output]
|
|
31
36
|
unknown = options.keys - allowed
|
|
32
37
|
raise ArgumentError, "Unknown LLMJudge provider options: #{unknown.join(', ')}" unless unknown.empty?
|
|
33
38
|
|
|
34
39
|
@strategy = options.fetch(:strategy, :ratings).to_sym
|
|
35
|
-
raise ArgumentError, 'strategy must be :ratings or :
|
|
40
|
+
raise ArgumentError, 'strategy must be :ratings, :single_request, or :committed' unless STRATEGIES.include?(@strategy)
|
|
36
41
|
@tie_breaker = options.fetch(:tie_breaker, :single_request).to_sym
|
|
37
42
|
raise ArgumentError, 'tie_breaker must be :single_request or :first' unless %i[single_request first].include?(@tie_breaker)
|
|
38
43
|
|
|
@@ -48,7 +53,7 @@ module RubyLLM
|
|
|
48
53
|
raise ArgumentError, 'scoring_protocol requires RubyLLM 2.1'
|
|
49
54
|
end
|
|
50
55
|
@temperature = options.fetch(:temperature, luna_defaults ? 0 : nil)
|
|
51
|
-
@max_output_tokens = options.fetch(:max_output_tokens, @strategy == :
|
|
56
|
+
@max_output_tokens = options.fetch(:max_output_tokens, @strategy == :ratings ? 4 : 8192)
|
|
52
57
|
@tie_break_max_output_tokens = options.fetch(:tie_break_max_output_tokens, 8192)
|
|
53
58
|
chat_provider_options = options.fetch(:chat_provider_options, {})
|
|
54
59
|
raise ArgumentError, 'chat_provider_options must be a Hash' unless chat_provider_options.is_a?(Hash)
|
|
@@ -59,6 +64,8 @@ module RubyLLM
|
|
|
59
64
|
@max_input_bytes = options[:max_input_bytes]
|
|
60
65
|
@malformed_retries = options.fetch(:malformed_retries, 1)
|
|
61
66
|
@max_workers = options.fetch(:max_workers, MAX_WORKERS)
|
|
67
|
+
@structured_output = options.fetch(:structured_output, false)
|
|
68
|
+
raise ArgumentError, 'structured_output must be true or false' unless [true, false].include?(@structured_output)
|
|
62
69
|
raise ArgumentError, 'max_workers must be a positive integer' unless @max_workers.is_a?(Integer) && @max_workers.positive?
|
|
63
70
|
raise ArgumentError, 'max_output_tokens must be positive' unless @max_output_tokens.is_a?(Integer) && @max_output_tokens.positive?
|
|
64
71
|
unless @tie_break_max_output_tokens.is_a?(Integer) && @tie_break_max_output_tokens.positive?
|
|
@@ -72,11 +79,12 @@ module RubyLLM
|
|
|
72
79
|
end
|
|
73
80
|
|
|
74
81
|
@scorer = scorer || method(:score_with_model)
|
|
75
|
-
@responder = responder
|
|
82
|
+
@responder = responder
|
|
76
83
|
end
|
|
77
84
|
|
|
78
85
|
def judge(input, questions:, model:)
|
|
79
|
-
return
|
|
86
|
+
return judge_one_call(input, questions, model, :probabilities) if @strategy == :single_request
|
|
87
|
+
return judge_one_call(input, questions, model, :committed) if @strategy == :committed
|
|
80
88
|
|
|
81
89
|
jobs = build_jobs(input, questions)
|
|
82
90
|
results = run(jobs) { |job| @scorer.call(job[:prompt]) }
|
|
@@ -88,7 +96,7 @@ module RubyLLM
|
|
|
88
96
|
chosen = answer(question, softmax(ratings))
|
|
89
97
|
if question.type == :choice && ratings.count(ratings.max) > 1 && @tie_breaker == :single_request
|
|
90
98
|
# Raises if the one-call judgment also ties, after its corrective retry.
|
|
91
|
-
judgment =
|
|
99
|
+
judgment = judge_one_call(input, { question.name => question }, model, :probabilities)
|
|
92
100
|
chosen = judgment.answers.fetch(question.name)
|
|
93
101
|
tie_breaks << { question: question.name, ratings:, judgment: }
|
|
94
102
|
end
|
|
@@ -113,34 +121,45 @@ module RubyLLM
|
|
|
113
121
|
|
|
114
122
|
private
|
|
115
123
|
|
|
116
|
-
|
|
124
|
+
# One request for every question. :probabilities asks for a distribution per question;
|
|
125
|
+
# :committed asks for one option per question and returns it with probability 1.
|
|
126
|
+
def judge_one_call(input, questions, model, mode)
|
|
117
127
|
specs = validated_options(input, questions)
|
|
118
|
-
original_prompt =
|
|
128
|
+
original_prompt = one_call_prompt(input, specs)
|
|
119
129
|
prompt = original_prompt
|
|
120
130
|
attempts = []
|
|
121
131
|
loop do
|
|
122
|
-
result =
|
|
132
|
+
result = respond(prompt, mode, specs)
|
|
123
133
|
attempts << result
|
|
124
134
|
begin
|
|
125
|
-
answers, reported, normalization =
|
|
135
|
+
answers, reported, normalization = mode == :committed ? parse_committed(result.fetch(:content), specs)
|
|
136
|
+
: parse_single_request(result.fetch(:content), specs)
|
|
137
|
+
raw = { method: mode == :committed ? 'committed_answers' : 'single_request_probabilities',
|
|
138
|
+
scoring_provider: @scoring_provider, scoring_model: @scoring_model,
|
|
139
|
+
reported_probabilities: reported, attempts: attempts.size }
|
|
140
|
+
raw[:reported_totals] = normalization if normalization
|
|
126
141
|
return Types::Judgment.new(
|
|
127
|
-
answers:, model: model.id,
|
|
128
|
-
tokens: aggregate_tokens(attempts.map { |attempt| attempt.fetch(:tokens) })
|
|
129
|
-
raw: { method: 'single_request_probabilities', scoring_provider: @scoring_provider,
|
|
130
|
-
scoring_model: @scoring_model, reported_probabilities: reported,
|
|
131
|
-
reported_totals: normalization, attempts: attempts.size }
|
|
142
|
+
answers:, model: model.id, raw:,
|
|
143
|
+
tokens: aggregate_tokens(attempts.map { |attempt| attempt.fetch(:tokens) })
|
|
132
144
|
)
|
|
133
145
|
rescue RubyLLM::Error => error
|
|
134
146
|
raise if attempts.size > @malformed_retries
|
|
135
147
|
|
|
136
148
|
prompt = "#{original_prompt}\n\nThe previous answer was invalid: #{error.message}. " \
|
|
137
149
|
"Previous answer: #{result.fetch(:content).to_s[0, 4000]}\n" \
|
|
138
|
-
'Return a complete corrected JSON object with every question
|
|
150
|
+
'Return a complete corrected JSON object with every question.'
|
|
139
151
|
end
|
|
140
152
|
end
|
|
141
153
|
end
|
|
142
154
|
|
|
143
|
-
def
|
|
155
|
+
def respond(prompt, mode, specs)
|
|
156
|
+
return @responder.call(prompt) if @responder
|
|
157
|
+
|
|
158
|
+
respond_with_model(prompt, mode:, schema: @structured_output ? answer_schema(mode, specs) : nil)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# The answers object keyed by question ID, accepting a fenced reply or a list of one-question objects.
|
|
162
|
+
def reported_answers(content, specs)
|
|
144
163
|
parsed = JSON.parse(strip_code_fence(content))
|
|
145
164
|
raise Error, 'Scoring model returned a non-object response' unless parsed.is_a?(Hash)
|
|
146
165
|
|
|
@@ -156,6 +175,44 @@ module RubyLLM
|
|
|
156
175
|
raise Error, "Question IDs must be #{expected_questions.inspect}; got #{actual.inspect}"
|
|
157
176
|
end
|
|
158
177
|
|
|
178
|
+
reported
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def parse_committed(content, specs)
|
|
182
|
+
reported = reported_answers(content, specs)
|
|
183
|
+
answers = specs.to_h do |question, options|
|
|
184
|
+
keys = options.map { |name, _| name.to_s }
|
|
185
|
+
value = reported.fetch(question.name.to_s)
|
|
186
|
+
value = { 'yes' => 'true', 'no' => 'false' }.fetch(value.to_s.downcase, value.to_s) if question.type == :probability
|
|
187
|
+
index = keys.index(value.to_s) unless value.is_a?(Hash) || value.is_a?(Array)
|
|
188
|
+
raise Error, "The answer for #{question.name} must be one of #{keys.inspect}; got #{value.inspect}" unless index
|
|
189
|
+
|
|
190
|
+
[question.name, answer(question, keys.each_index.map { |position| position == index ? 1.0 : 0.0 })]
|
|
191
|
+
end
|
|
192
|
+
[answers, reported, nil]
|
|
193
|
+
rescue JSON::ParserError, KeyError, TypeError => error
|
|
194
|
+
raise Error, "Scoring model returned invalid JSON answers: #{error.message}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A strict JSON Schema that admits only complete answers to these questions.
|
|
198
|
+
def answer_schema(mode, specs)
|
|
199
|
+
properties = specs.to_h do |question, options|
|
|
200
|
+
keys = options.map { |name, _| name.to_s }
|
|
201
|
+
schema = if mode == :committed
|
|
202
|
+
{ type: 'string', enum: keys }
|
|
203
|
+
else
|
|
204
|
+
{ type: 'object', properties: keys.to_h { |key| [key, { type: 'number' }] },
|
|
205
|
+
required: keys, additionalProperties: false }
|
|
206
|
+
end
|
|
207
|
+
[question.name.to_s, schema]
|
|
208
|
+
end
|
|
209
|
+
answers = { type: 'object', properties:, required: properties.keys, additionalProperties: false }
|
|
210
|
+
{ name: 'judgment', strict: true,
|
|
211
|
+
schema: { type: 'object', properties: { answers: }, required: ['answers'], additionalProperties: false } }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse_single_request(content, specs)
|
|
215
|
+
reported = reported_answers(content, specs)
|
|
159
216
|
normalization = {}
|
|
160
217
|
answers = specs.to_h do |question, options|
|
|
161
218
|
distribution = reported.fetch(question.name.to_s)
|
|
@@ -187,7 +244,9 @@ module RubyLLM
|
|
|
187
244
|
raise Error, "Scoring model returned invalid JSON probabilities: #{error.message}"
|
|
188
245
|
end
|
|
189
246
|
|
|
190
|
-
|
|
247
|
+
# The state comes first: listing the questions first made Luna 6 retry more and answer less
|
|
248
|
+
# accurately on the benchmark cases.
|
|
249
|
+
def one_call_prompt(input, specs)
|
|
191
250
|
request = {
|
|
192
251
|
state: input,
|
|
193
252
|
questions: specs.map do |question, options|
|
|
@@ -318,13 +377,16 @@ module RubyLLM
|
|
|
318
377
|
end
|
|
319
378
|
end
|
|
320
379
|
|
|
321
|
-
def respond_with_model(prompt)
|
|
380
|
+
def respond_with_model(prompt, mode:, schema: nil)
|
|
322
381
|
limit = @strategy == :ratings ? @tie_break_max_output_tokens : @max_output_tokens
|
|
323
|
-
|
|
324
|
-
|
|
382
|
+
instructions = mode == :committed ? COMMITTED_INSTRUCTIONS : SINGLE_REQUEST_INSTRUCTIONS
|
|
383
|
+
response = scoring_chat(instructions:, max_output_tokens: limit, schema:).ask(prompt)
|
|
384
|
+
# With a schema, RubyLLM parses the JSON reply into a Hash.
|
|
385
|
+
content = response.content
|
|
386
|
+
{ content: content.is_a?(String) ? content : JSON.generate(content), tokens: response.tokens }
|
|
325
387
|
end
|
|
326
388
|
|
|
327
|
-
def scoring_chat(instructions: SYSTEM_INSTRUCTIONS, max_output_tokens: @max_output_tokens)
|
|
389
|
+
def scoring_chat(instructions: SYSTEM_INSTRUCTIONS, max_output_tokens: @max_output_tokens, schema: nil)
|
|
328
390
|
context = RubyLLM::Context.new(@config)
|
|
329
391
|
chat = if NATIVE
|
|
330
392
|
context.chat(model: @scoring_model, provider: @scoring_provider,
|
|
@@ -334,6 +396,7 @@ module RubyLLM
|
|
|
334
396
|
end
|
|
335
397
|
chat.with_instructions(instructions)
|
|
336
398
|
chat.with_temperature(@temperature) unless @temperature.nil?
|
|
399
|
+
chat.with_schema(schema) if schema
|
|
337
400
|
if NATIVE
|
|
338
401
|
chat.with_max_output_tokens(max_output_tokens)
|
|
339
402
|
chat.with_provider_options(deep_compact(@chat_provider_options))
|
|
@@ -7,7 +7,10 @@ module RubyLLM
|
|
|
7
7
|
'http://127.0.0.1'
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
# RubyLLM passes images as +with:+; they are not supported yet.
|
|
11
|
+
def judge(input, questions:, model:, with: [], provider_options: {})
|
|
12
|
+
raise UnsupportedAttachmentError, Array(with).first.mime_type unless Array(with).empty?
|
|
13
|
+
|
|
11
14
|
Engine.new(config:, model: model.id, provider_options:).judge(input, questions:, model:)
|
|
12
15
|
end
|
|
13
16
|
|