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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c438b1693068b5c47ada88cf13b23ed8786c597ed4c349d33d8c012f8edaedd2
4
- data.tar.gz: ca228933f0f0772c7f523c788d9391edf012138b7f0faec84466da3026b1eb9b
3
+ metadata.gz: fc81f9575b6468ad1a7ea81b19d777ea169eca0619cf1995c59c0aab01128254
4
+ data.tar.gz: 6052e01d0b02b576c25fe5baa0545a8f89e9060e12d42ae65f3fa99e8e14f5f7
5
5
  SHA512:
6
- metadata.gz: b50f0831b1e86781c9d0aaac5d9d96b69284b0e527841119df6a73f7379bee194b67b7e09bad5e72778ea35851749bff9e0787664c815f90ff83e3d653a4f6a9
7
- data.tar.gz: c49adc022376e4b959c73d48704b293a536fd6e8a9580eaf8b495676b93c236ad7c02d24f748814c5394edb26edc7668bfee91acaf1fec03a43cbabb39153a18
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 :single_request' unless %i[ratings single_request].include?(@strategy)
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 == :single_request ? 8192 : 4)
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 || method(:respond_with_model)
82
+ @responder = responder
76
83
  end
77
84
 
78
85
  def judge(input, questions:, model:)
79
- return judge_single_request(input, questions, model) if @strategy == :single_request
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 = judge_single_request(input, { question.name => question }, model)
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
- def judge_single_request(input, questions, model)
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 = single_request_prompt(input, specs)
128
+ original_prompt = one_call_prompt(input, specs)
119
129
  prompt = original_prompt
120
130
  attempts = []
121
131
  loop do
122
- result = @responder.call(prompt)
132
+ result = respond(prompt, mode, specs)
123
133
  attempts << result
124
134
  begin
125
- answers, reported, normalization = parse_single_request(result.fetch(:content), specs)
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 and option.'
150
+ 'Return a complete corrected JSON object with every question.'
139
151
  end
140
152
  end
141
153
  end
142
154
 
143
- def parse_single_request(content, specs)
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
- def single_request_prompt(input, specs)
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
- response = scoring_chat(instructions: SINGLE_REQUEST_INSTRUCTIONS, max_output_tokens: limit).ask(prompt)
324
- { content: response.content.to_s, tokens: response.tokens }
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
- def judge(input, questions:, model:, provider_options: {})
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
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RubyLLM
4
4
  module LLMJudge
5
- VERSION = '0.1.3'
5
+ VERSION = '0.1.4'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-llm_judge
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara