squishling 0.3.0 → 0.4.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.
@@ -15,18 +15,14 @@ module Squishling
15
15
  # role (:judge) skips squish_validate and names the judge in errors and logs.
16
16
  Prompt = Data.define(:instructions, :schema, :input, :role)
17
17
 
18
- # One of a sampling harness's two samples, between rounds: the steps it may attempt, in order.
19
- Sample = Struct.new(:name, :steps, :chat, :rejected, :result)
20
-
21
18
  def initialize(definition, receiver, inputs)
22
19
  @definition = definition
23
20
  @receiver = receiver
24
21
  @inputs = inputs
25
- @client = LLMClient.new(definition.label)
26
- @output_check = OutputCheck.new(definition, receiver, inputs)
27
22
  end
28
23
 
29
- # Runs the harness (Definition#harness) over the escalation (Definition#escalation_path).
24
+ # Runs the harness (Definition#harness) over a Run of the call: the harness decides how the escalation is
25
+ # used, and what it returns is checked against the output schema's type.
30
26
  def call
31
27
  purpose = @definition.purpose(@receiver)
32
28
  schema = @definition.schema
@@ -34,173 +30,23 @@ module Squishling
34
30
  raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
35
31
 
36
32
  harness = @definition.harness
37
- path = @definition.escalation_path
38
33
  body = payload
39
34
  prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:,
40
35
  input: JSON.generate(body), role: nil)
41
- return escalate(path, prompt) unless harness.squishsum?
42
-
43
- # The sample steps and the judge are resolved before any sample is requested, so a missing one fails
44
- # before costing anything.
45
- sample_steps, rest = sampling_plan(path, harness)
46
- judge = harness.judged? && Judge.new(definition: @definition, receiver: @receiver, client: @client, harness:,
47
- rest:, purpose:, payload: body, escalate: method(:escalate),
48
- log_failure: method(:log_failure))
49
- sample(sample_steps, prompt, harness, judge)
36
+ run = Run.new(definition: @definition, receiver: @receiver, inputs: @inputs, prompt:, payload: body, purpose:,
37
+ harness:)
38
+ accepted(harness.klass.new.call(run, **harness.options), run, harness)
50
39
  end
51
40
 
52
41
  private
53
42
 
54
- # Splits the escalation into runs of consecutive identical steps (a step's attempts). A squishsum samples
55
- # the first run twice; an ensemble samples the first run, then the second. Returns each sample's steps and
56
- # the rest of the escalation, which is where a judge looks for its default step.
57
- def sampling_plan(path, harness)
58
- runs = path.chunk_while { |step, following| step == following }.to_a
59
- if harness.ensemble? && runs.size < 2
60
- raise ConfigurationError, "#{@definition.label}: the #{harness.type} harness needs a second escalation " \
61
- "step to sample (adjacent identical steps count as one step's attempts)"
62
- end
63
-
64
- return [runs.first(2), runs.drop(2).flatten(1)] if harness.ensemble?
65
-
66
- [[runs.first, runs.first], runs.drop(1).flatten(1)]
67
- end
68
-
69
- # Makes each attempt of the path until an output passes the schema and the squish_validate check.
70
- # Consecutive attempts on the same step continue the same conversation, so the model sees what it got wrong;
71
- # a new step starts a fresh chat told about the last rejected output (unless the step sets
72
- # forward_rejected: false).
73
- def escalate(path, prompt)
74
- chat = nil
75
- rejected = nil # [raw content, errors] of the last invalid output
76
- models = []
77
-
78
- path.each.with_index(1) do |step, attempt|
79
- models << step.model
80
- last = attempt == path.size
81
- if chat && step == path[attempt - 2]
82
- message = retry_message(rejected.last)
83
- else
84
- chat = start_chat(step, prompt)
85
- message = rejected && step.forward_rejected ? escalation_message(prompt.input, *rejected) : prompt.input
86
- end
87
-
88
- begin
89
- response = @client.ask(chat, message, step)
90
- rescue LLMError => e
91
- squawk(prompt, path, attempt, nil, e)
92
- raise if last
93
-
94
- # The failed request may be half-recorded in this chat, so the next attempt starts a fresh one.
95
- chat = nil
96
- log_failure(prompt.role, attempt, path, e.message)
97
- next
98
- end
99
-
100
- result, errors = @output_check.call(response.content, prompt)
101
- if errors.empty?
102
- squawk(prompt, path, attempt, response, nil)
103
- return result
104
- end
105
-
106
- error = InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models: models.dup,
107
- source: source(prompt.role))
108
- squawk(prompt, path, attempt, response, error)
109
- raise error if last
110
-
111
- rejected = [response.content, errors]
112
- log_failure(prompt.role, attempt, path, errors.join("; "))
113
- end
114
- end
115
-
116
- # Two samples, each on its own steps and retried per their attempts the same way escalate retries.
117
- # Each round sends the pending samples' requests concurrently; everything else runs on this thread.
118
- def sample(sample_steps, prompt, harness, judge)
119
- samples = %w[a b].zip(sample_steps).map { |name, steps| Sample.new(name, steps) }
120
-
121
- 1.upto(sample_steps.map(&:size).max) do |attempt|
122
- pending = samples.reject(&:result)
123
- break if pending.empty?
124
-
125
- jobs = pending.map do |sample|
126
- step = sample.steps[attempt - 1]
127
- message = sample_message(sample, step, prompt)
128
- -> { @client.ask(sample.chat, message, step) }
129
- end
130
- outcomes = LLMClient.concurrently(jobs)
131
- # A setup mistake (or anything that isn't a failed request) ends the call, whichever sample hit it.
132
- fatal = outcomes.map(&:last).find { |error| error && !error.is_a?(LLMError) }
133
- raise fatal if fatal
134
-
135
- pending.zip(outcomes).each { |sample, outcome| record(sample, outcome, prompt, attempt) }
136
- end
137
-
138
- settle(samples, harness, judge)
139
- end
140
-
141
- # The next message for a sample: a retry in its chat after invalid output, otherwise a fresh chat.
142
- def sample_message(sample, step, prompt)
143
- return retry_message(sample.rejected.last) if sample.chat
144
-
145
- sample.chat = start_chat(step, prompt)
146
- sample.rejected && step.forward_rejected ? escalation_message(prompt.input, *sample.rejected) : prompt.input
147
- end
148
-
149
- def record(sample, (response, error), prompt, attempt)
150
- steps = sample.steps
151
- last = attempt == steps.size
152
- role = "sample #{sample.name}"
153
- if error
154
- squawk(prompt, steps, attempt, nil, error)
155
- raise error if last
156
-
157
- sample.chat = nil
158
- return log_failure(role, attempt, steps, error.message)
159
- end
160
-
161
- result, errors = @output_check.call(response.content, prompt)
162
- if errors.empty?
163
- squawk(prompt, steps, attempt, response, nil)
164
- return sample.result = result
165
- end
166
-
167
- models = steps.first(attempt).map(&:model)
168
- invalid = InvalidOutputError.new(errors:, raw: response.content, attempts: attempt, models:)
169
- squawk(prompt, steps, attempt, response, invalid)
170
- raise invalid if last
43
+ # What a harness returns must be a result its run produced: validated output, typed like the deterministic
44
+ # path's. One built by hand, or a hash, would skip the schema and squish_validate.
45
+ def accepted(value, run, harness)
46
+ return value if run.produced?(value)
171
47
 
172
- sample.rejected = [response.content, errors]
173
- log_failure(role, attempt, steps, errors.join("; "))
174
- end
175
-
176
- # Agreeing samples are accepted; otherwise the judge, if any, picks one or the call fails.
177
- def settle(samples, harness, judge)
178
- first, second = samples.map(&:result)
179
- return first if agree?(first, second, harness.compare)
180
-
181
- models = samples.map { |sample| sample.steps.first.model }
182
- unless judge
183
- log_warning("samples disagreed")
184
- raise DisagreementError.new(candidates: [first, second], models:)
185
- end
186
-
187
- log_warning("samples disagreed, asking the judge (#{judge.step.display_name})")
188
- choice, reason, detail = judge.verdict(first, second)
189
- return { a: first, b: second }.fetch(choice) unless choice == :neither
190
-
191
- raise DisagreementError.new(candidates: [first, second], verdict: :neither, reason:, detail:,
192
- models: models + [judge.step.model])
193
- end
194
-
195
- # compare: is the developer's own code, so its exceptions propagate unwrapped.
196
- def agree?(first, second, compare)
197
- return Schema.jsonify(first.to_h) == Schema.jsonify(second.to_h) unless compare
198
-
199
- @receiver.instance_exec(first, second, **@inputs, &compare) ? true : false
200
- end
201
-
202
- def start_chat(step, prompt)
203
- @client.chat(step, instructions: prompt.instructions, schema: prompt.schema.llm_schema)
48
+ raise ConfigurationError, "#{@definition.label}: the #{harness.type} harness must return a result from " \
49
+ "its run (escalate, samples, or a judge's pick), got #{value.class}"
204
50
  end
205
51
 
206
52
  # Only the arguments, the declared squish_context names, and a squish! call's context leave the process.
@@ -233,68 +79,5 @@ module Squishling
233
79
 
234
80
  @receiver.instance_variable_get(:"@#{name}")
235
81
  end
236
-
237
- def retry_message(errors)
238
- "Your previous response was rejected:\n- #{errors.join("\n- ")}\nRespond again with corrected JSON only."
239
- end
240
-
241
- # The first message to a fresh chat after an earlier model's output was rejected. The rejected output is
242
- # model-generated and may cross providers, so it is capped at MAX_FORWARDED_CHARS.
243
- def escalation_message(input, raw, errors)
244
- previous = rejected_text(raw)
245
- previous = "(an empty response)" if previous.strip.empty? || raw.nil?
246
- if previous.length > MAX_FORWARDED_CHARS
247
- omitted = previous.length - MAX_FORWARDED_CHARS
248
- previous = "#{previous[0, MAX_FORWARDED_CHARS]}... [truncated, #{omitted} more characters]"
249
- end
250
- "#{input}\n\nA previous attempt at this request returned:\n#{previous}\n" \
251
- "It was rejected:\n- #{errors.join("\n- ")}\nRespond with corrected JSON only."
252
- end
253
-
254
- # The rejected output as text. Output that was rejected for being unrepresentable in JSON (Infinity, invalid
255
- # UTF-8) can't be serialized or measured as is, so it is scrubbed or replaced rather than raising.
256
- def rejected_text(raw)
257
- return utf8(raw).scrub("?") if raw.is_a?(String)
258
-
259
- JSON.generate(raw)
260
- rescue JSON::JSONError
261
- "(a response JSON can't represent)"
262
- end
263
-
264
- # A binary-tagged string counts every byte as valid, so it is read as UTF-8 before scrubbing.
265
- def utf8(text)
266
- text.encoding == Encoding::BINARY ? text.dup.force_encoding(Encoding::UTF_8) : text
267
- end
268
-
269
- # Runs the observability hook, if any, with this attempt's raw output (nil when the call itself failed),
270
- # the error that ended it (nil when it was accepted), and what was asked.
271
- def squawk(prompt, path, attempt, response, error)
272
- hook = @definition.squawk
273
- return unless hook
274
-
275
- step = path[attempt - 1]
276
- metadata = {
277
- label: @definition.label, attempt:, attempts: path.size, final: attempt == path.size,
278
- model: response&.model || step.model, provider: step.provider, params: step.params, input: prompt.input,
279
- usage: response&.tokens&.to_h
280
- }
281
- Squawk.call(hook, output: response&.content, metadata:, error:)
282
- end
283
-
284
- def source(role)
285
- role == :judge ? "Judge" : "LLM"
286
- end
287
-
288
- def log_failure(role, attempt, path, reason)
289
- step = path[attempt - 1]
290
- next_step = path[attempt]
291
- action = next_step == step ? "retrying" : "escalating to #{next_step.display_name}"
292
- who = role ? "#{role} attempt" : "attempt"
293
- log_warning("#{who} #{attempt} of #{path.size} (#{step.display_name}) failed, #{action}: #{reason}")
294
- end
295
-
296
- def log_warning(message)
297
- Squishling.config.logger&.warn("[Squishling] #{@definition.label} #{message}")
298
- end
299
82
  end
300
83
  end
@@ -1,12 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- # Picks between a judged harness's two disagreeing samples (judged_squishsum or judged_ensemble), or rejects
5
- # both. The judge is the harness's judge: step, or else the escalation's next step after the ones the samples
6
- # ran on. A chat judge answers with a strict verdict schema; a :judgment judge (a System One decision model such
7
- # as Jev) answers one choice question through RubyLLM.judge, and its pick counts only at or above
8
- # min_confidence.
4
+ # Picks between two disagreeing samples, or rejects both (see Run#judge). The judge is the given judge:
5
+ # step, or else the escalation's next stage after the first. A chat judge answers with a strict verdict
6
+ # schema; a :judgment judge (a System One decision model such as Jev) answers one choice question through
7
+ # RubyLLM.judge, and its pick counts only at or above min_confidence.
9
8
  class Judge
9
+ # The judge kinds: :chat (a chat model answering VERDICT_SCHEMA) and :judgment (a System One decision model
10
+ # through RubyLLM.judge). A judge is an escalation step (see ModelPath) plus these keys: type: (default :chat)
11
+ # and, for :judgment, min_confidence: (the probability the chosen candidate needs).
12
+ TYPES = %i[chat judgment].freeze
13
+ STEP_KEYS = %i[type min_confidence].freeze
14
+ DEFAULT_MIN_CONFIDENCE = 0.8
15
+
10
16
  VERDICT_SCHEMA = {
11
17
  "type" => "object",
12
18
  "properties" => {
@@ -29,16 +35,27 @@ module Squishling
29
35
  neither: "Neither candidate is clearly correct"
30
36
  }.freeze
31
37
 
32
- def initialize(definition:, receiver:, client:, harness:, rest:, purpose:, payload:, escalate:, log_failure:)
38
+ # What a judge decided: :a, :b, or :neither, with its reason. The detail is text Squishling wrote (nil for a
39
+ # chat judge, whose reason is model-written) and is safe for error messages.
40
+ Verdict = Data.define(:choice, :reason, :detail)
41
+
42
+ # judge: is a normalized judge step (see Harness::JudgedSquishsum) or nil for the escalation's next stage,
43
+ # instructions: a String, a Proc evaluated against the receiver, or nil for the default prompt.
44
+ def initialize(definition:, receiver:, client:, judge:, instructions:, path:, purpose:, payload:, escalate:,
45
+ log_failure:, guard:, harness_type:)
33
46
  @definition = definition
34
47
  @receiver = receiver
35
48
  @client = client
36
- @harness = harness
49
+ @judge = judge
50
+ @instructions = instructions
51
+ check!
52
+ @guard = guard
53
+ @harness_type = harness_type
37
54
  @purpose = purpose
38
55
  @payload = payload
39
56
  @escalate = escalate
40
57
  @log_failure = log_failure
41
- @steps = resolve_steps(rest)
58
+ @steps = resolve_steps(path)
42
59
  end
43
60
 
44
61
  # The judge's first attempt, for logs and DisagreementError#models.
@@ -46,31 +63,58 @@ module Squishling
46
63
  @steps.first
47
64
  end
48
65
 
49
- # [:a | :b, reason, detail] for the chosen candidate, or [:neither, reason, detail]. The detail is text
50
- # Squishling wrote (nil for a chat judge, whose reason is model-written) and is safe for error messages.
66
+ # The Verdict for the two candidates: :a or :b for the chosen one, or :neither.
51
67
  def verdict(first, second)
68
+ @guard.call
52
69
  candidates = { a: Schema.jsonify(first.to_h), b: Schema.jsonify(second.to_h) }
53
70
  judgment? ? judgment(candidates) : chat(candidates)
54
71
  end
55
72
 
56
73
  private
57
74
 
75
+ # What Run#judge was given. A harness declaration is checked earlier, with its own messages.
76
+ def check!
77
+ label = "#{@definition.label}: judge"
78
+ if @judge
79
+ type = @judge.fetch(:type)
80
+ unless TYPES.include?(type)
81
+ raise ConfigurationError, "#{label} type: must be one of #{TYPES.join(', ')}, got #{type.inspect}"
82
+ end
83
+
84
+ if type == :chat && @judge.key?(:min_confidence)
85
+ raise ConfigurationError, "#{label} min_confidence: only applies to type: :judgment judges"
86
+ end
87
+
88
+ minimum = @judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
89
+ unless minimum.is_a?(Numeric) && minimum.between?(0, 1)
90
+ raise ConfigurationError, "#{label} min_confidence: must be a number from 0 to 1, got #{minimum.inspect}"
91
+ end
92
+ end
93
+ return if @instructions.nil? || @instructions.is_a?(Proc) || non_blank_string?(@instructions)
94
+
95
+ raise ConfigurationError, "#{label} instructions must be a non-blank String or a Proc"
96
+ end
97
+
98
+ def non_blank_string?(value)
99
+ value.is_a?(String) && !value.strip.empty?
100
+ end
101
+
58
102
  def judgment?
59
- @harness.judge&.fetch(:type) == :judgment
103
+ @judge&.fetch(:type) == :judgment
60
104
  end
61
105
 
62
106
  # A declared judge brings its own model and provider. A chat judge gets the method's generation params
63
107
  # under its own, like any escalation step; a judgment judge sends only its own params, as provider options.
64
- def resolve_steps(rest)
65
- if (judge = @harness.judge)
108
+ def resolve_steps(path)
109
+ if (judge = @judge)
66
110
  params = judge[:type] == :judgment ? {} : @definition.params
67
111
  return ModelPath.steps([judge], provider: nil, params:)
68
112
  end
69
113
 
114
+ rest = path.drop_while { |step| step == path.first }
70
115
  if rest.empty?
71
- ordinal = @harness.ensemble? ? "third" : "second"
72
- raise ConfigurationError, "#{@definition.label}: the #{@harness.type} harness needs a judge: or a " \
73
- "#{ordinal} escalation step to judge with"
116
+ raise ConfigurationError, "#{@definition.label}: the #{@harness_type} harness needs a judge: or a second " \
117
+ "escalation step to judge with"
74
118
  end
75
119
 
76
120
  rest.take_while { |step| step == rest.first }
@@ -83,7 +127,7 @@ module Squishling
83
127
  schema: Schema.for(VERDICT_SCHEMA), input: JSON.generate(state(candidates)), role: :judge
84
128
  )
85
129
  result = @escalate.call(@steps, prompt)
86
- [result[:verdict].to_sym, result[:reason], nil]
130
+ Verdict.new(choice: result[:verdict].to_sym, reason: result[:reason], detail: nil)
87
131
  end
88
132
 
89
133
  def judgment(candidates)
@@ -100,12 +144,12 @@ module Squishling
100
144
  # The threshold is checked on the exact probability; it's rounded only for the reason text.
101
145
  def interpret(choice, probabilities)
102
146
  probability = probabilities.to_h.find { |key, _| key.to_s == choice.to_s }&.last.to_f
103
- minimum = @harness.judge[:min_confidence]
147
+ minimum = @judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
104
148
  shown = probability.round(3)
105
149
  picked = %i[a b].include?(choice)
106
150
  if picked && probability >= minimum
107
151
  text = "chosen with probability #{shown}"
108
- return [choice, text, text]
152
+ return Verdict.new(choice:, reason: text, detail: text)
109
153
  end
110
154
 
111
155
  text =
@@ -113,13 +157,13 @@ module Squishling
113
157
  elsif picked then "the judge chose #{choice} with probability #{shown}, below min_confidence #{minimum}"
114
158
  else "the judge returned an unrecognized choice (probability #{shown})"
115
159
  end
116
- [:neither, text, text]
160
+ Verdict.new(choice: :neither, reason: text, detail: text)
117
161
  end
118
162
 
119
163
  # A judgment always matches its questions, so only a failed request (LLMError) moves on to the next attempt.
120
164
  def judge_with_retries(input, questions)
121
165
  @steps.each.with_index(1) do |step, attempt|
122
- return @client.judge(input, questions:, step:)
166
+ return @client.observed_judgment(input, questions:, step:, attempt:, attempts: @steps.size)
123
167
  rescue LLMError => e
124
168
  raise if attempt == @steps.size
125
169
 
@@ -132,7 +176,7 @@ module Squishling
132
176
  end
133
177
 
134
178
  def judge_instructions
135
- value = @harness.judge_instructions || Harness::DEFAULT_JUDGE_INSTRUCTIONS
179
+ value = @instructions || Harness::DEFAULT_JUDGE_INSTRUCTIONS
136
180
  value = @receiver.instance_exec(&value) if value.is_a?(Proc)
137
181
  return value if value.is_a?(String) && !value.strip.empty?
138
182
 
@@ -50,8 +50,12 @@ module Squishling
50
50
 
51
51
  private_class_method :capture, :instrumentation_context, :with_instrumentation_context
52
52
 
53
- def initialize(label)
53
+ # purpose, harness (the harness type), and handed_off (squish! sent the call here) are only told to the observer.
54
+ def initialize(label, purpose:, harness:, handed_off:)
54
55
  @label = label
56
+ @purpose = purpose
57
+ @harness = harness
58
+ @handed_off = handed_off
55
59
  end
56
60
 
57
61
  # A fresh chat on one escalation step, with the system prompt, the strict output schema, and its params.
@@ -68,8 +72,11 @@ module Squishling
68
72
  # escalation (or propagates from the last one). A 400 means the request we built is invalid (an
69
73
  # unsupported param, a schema the provider rejects), so it's a setup mistake: escalating or a
70
74
  # fallback would otherwise hide it on every call.
71
- def ask(chat, message, step)
72
- request(step) { chat.ask(message) }
75
+ # The request runs inside the configured observer (see Observer), on this thread: for samples, the worker's.
76
+ # role is nil for an escalation attempt, :sample (with its name) or :judge; attempt counts in attempts.
77
+ def ask(chat, message, step, attempt:, attempts:, role: nil, sample: nil)
78
+ call = call_info(step, :chat, role:, sample:, attempt:, attempts:)
79
+ Observer.around(call) { request(step) { chat.ask(message) } }
73
80
  end
74
81
 
75
82
  # A System One judgment (RubyLLM.judge) on one step. The step's params are sent as provider options.
@@ -93,8 +100,19 @@ module Squishling
93
100
  end
94
101
  end
95
102
 
103
+ # #judge inside the configured observer (see Observer), attempt counting in attempts.
104
+ def observed_judgment(input, questions:, step:, attempt:, attempts:)
105
+ call = call_info(step, :judgment, role: :judge, attempt:, attempts:)
106
+ Observer.around(call) { judge(input, questions:, step:) }
107
+ end
108
+
96
109
  private
97
110
 
111
+ def call_info(step, request, attempt:, attempts:, role: nil, sample: nil)
112
+ Observer::Call.new(label: @label, purpose: @purpose, harness: @harness, handed_off: @handed_off, role:, sample:,
113
+ attempt:, attempts:, model: step.model, provider: step.provider, request:)
114
+ end
115
+
98
116
  def raised_by_ruby_llm?(error)
99
117
  error.backtrace&.first&.start_with?("#{RUBY_LLM_LIB}/") || false
100
118
  end
@@ -113,15 +131,27 @@ module Squishling
113
131
  raise ConfigurationError, "#{@label}: invalid params #{params.inspect} (#{e.message})"
114
132
  end
115
133
 
134
+ # A provider's or transport's own message never goes into ours: a provider can echo request content or part of
135
+ # an API key in it, and ours reach logs and error trackers. The original stays as #cause for whoever
136
+ # chooses to read it. RubyLLM's own ConfigurationError is local (it names a missing setting), so it is kept.
116
137
  def request(step)
117
138
  yield
118
- rescue RubyLLM::ConfigurationError, RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
139
+ rescue RubyLLM::ConfigurationError => e
119
140
  raise ConfigurationError, "#{@label}: #{e.class}: #{e.message}"
141
+ rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
142
+ raise ConfigurationError, "#{@label}: #{describe_failure(e)}"
120
143
  rescue RubyLLM::BadRequestError => e
121
144
  raise ConfigurationError,
122
- "#{@label}: the provider rejected the request (#{e.message})#{params_hint(step.params)}"
145
+ "#{@label}: the provider rejected the request (#{describe_failure(e)})#{params_hint(step.params)}"
123
146
  rescue RubyLLM::Error, Faraday::Error => e
124
- raise LLMError, "#{@label}: #{e.class}: #{e.message}"
147
+ raise LLMError, "#{@label}: #{describe_failure(e)}"
148
+ end
149
+
150
+ # The error's class and HTTP status, when it has one, which is all of it that is ours to repeat.
151
+ def describe_failure(error)
152
+ response = error.response
153
+ status = response.is_a?(Hash) ? response[:status] : (response.status if response.respond_to?(:status))
154
+ status ? "#{error.class}, HTTP #{status}" : error.class.to_s
125
155
  end
126
156
 
127
157
  # Models missing from RubyLLM's registry (e.g. newly released ones) are only usable when a
@@ -8,9 +8,11 @@ module Squishling
8
8
  # "claude-sonnet-5-5",
9
9
  # { model: "claude-opus-5-5", params: { thinking: { effort: :high } } }
10
10
  # ]
11
- # attempts: (default 1) retries a step; order: (all steps or none, unique, lowest first) makes the order
12
- # explicit instead of positional. forward_rejected: false starts a step from the original input alone, without
13
- # the previous step's rejected output.
11
+ # attempts: (default 1) retries a step; priority: (all steps or none, lowest first) makes the order explicit
12
+ # instead of positional. Steps sharing a priority are one tier: each call rotates which of them goes first
13
+ # (see Rotation), and the others follow before the next tier, unless a step in the tier sets rotate: false.
14
+ # order: is the deprecated name of priority:. forward_rejected: false starts a step from the original input
15
+ # alone, without the previous step's rejected output.
14
16
  module ModelPath
15
17
  # One attempt: the model, its provider, the fully resolved generation params, and whether the previous
16
18
  # step's rejected output is shown to it.
@@ -21,7 +23,7 @@ module Squishling
21
23
  end
22
24
  end
23
25
 
24
- STEP_KEYS = %i[model provider params attempts order forward_rejected].freeze
26
+ STEP_KEYS = %i[model provider params attempts priority order rotate forward_rejected].freeze
25
27
 
26
28
  module_function
27
29
 
@@ -57,43 +59,62 @@ module Squishling
57
59
  end
58
60
 
59
61
  # One Step per attempt. The level's provider applies to steps that don't name their own, and each
60
- # step's params are merged over the level-resolved params.
61
- def steps(path, provider:, params:)
62
- path.flat_map do |step|
62
+ # step's params are merged over the level-resolved params. turn is this call's place in the method's rotation
63
+ # (see Rotation): each tier of tied priorities starts turn steps further along.
64
+ def steps(path, provider:, params:, turn: 0)
65
+ rotated(path, turn).flat_map do |step|
63
66
  attempt = Step.new(model: step[:model], provider: step[:provider] || provider,
64
67
  params: Params.resolve(params, step[:params]), forward_rejected: step[:forward_rejected])
65
68
  [attempt] * step[:attempts]
66
69
  end
67
70
  end
68
71
 
72
+ # Whether any tier of the path has several models to rotate between.
73
+ def rotating?(path)
74
+ tiers(path).any? { |tier| rotates?(tier) }
75
+ end
76
+
77
+ # Steps sorted by priority, lowest first, ties kept in the order declared.
69
78
  def ordered(steps, label)
70
- numbered = steps.count { |step| step.key?(:order) }
71
- return steps.map { |step| step.except(:order) } if numbered.zero?
79
+ numbered = steps.count { |step| step.key?(:priority) }
80
+ return steps if numbered.zero?
72
81
 
73
82
  if numbered < steps.size
74
- raise ConfigurationError, "#{label}: give every step an order: or none (#{numbered} of #{steps.size} have one)"
83
+ raise ConfigurationError,
84
+ "#{label}: give every step a priority: or none (#{numbered} of #{steps.size} have one)"
75
85
  end
76
86
 
77
- orders = steps.map { |step| step[:order] }
78
- duplicates = orders.tally.select { |_, count| count > 1 }.keys
79
- raise ConfigurationError, "#{label}: duplicate order: #{duplicates.join(', ')}" if duplicates.any?
87
+ steps.each_with_index.sort_by { |step, index| [step[:priority], index] }.map(&:first)
88
+ end
89
+
90
+ # Runs of steps with the same priority. A step without one is a tier of its own.
91
+ def tiers(path)
92
+ path.slice_when { |before, after| before[:priority].nil? || before[:priority] != after[:priority] }
93
+ end
94
+
95
+ def rotates?(tier)
96
+ tier.size > 1 && tier.none? { |step| step[:rotate] == false }
97
+ end
80
98
 
81
- steps.sort_by { |step| step[:order] }.map { |step| step.except(:order) }
99
+ def rotated(path, turn)
100
+ tiers(path).flat_map { |tier| rotates?(tier) ? tier.rotate(turn % tier.size) : tier }
82
101
  end
83
102
 
84
- def normalize_step(step, label)
103
+ # extras: names more keys a step Hash may carry for its caller (a judge's type:, say). They are kept as given,
104
+ # for the caller to check.
105
+ def normalize_step(step, label, extras: [])
85
106
  case step
86
107
  when String, Symbol
87
108
  { model: model_name(step, label), provider: nil, params: nil, attempts: 1, forward_rejected: true }
88
- when Hash then normalize_hash(step, label)
109
+ when Hash then normalize_hash(step, label, extras)
89
110
  else
90
111
  raise ConfigurationError, "#{label}: each step must be a model name or a Hash with model:, got #{step.inspect}"
91
112
  end
92
113
  end
93
114
 
94
- def normalize_hash(step, label)
115
+ def normalize_hash(step, label, extras)
95
116
  step = step.transform_keys(&:to_sym)
96
- unknown = step.keys - STEP_KEYS
117
+ unknown = step.keys - STEP_KEYS - extras
97
118
  raise ConfigurationError, "#{label}: unknown step option(s) #{unknown.join(', ')}" if unknown.any?
98
119
  unless step[:model].is_a?(String) || step[:model].is_a?(Symbol)
99
120
  raise ConfigurationError, "#{label}: a step needs a model: name, got #{step.inspect}"
@@ -104,8 +125,13 @@ module Squishling
104
125
  unless attempts.is_a?(Integer) && attempts.positive?
105
126
  raise ConfigurationError, "#{label} (#{model}): attempts: must be a positive Integer, got #{attempts.inspect}"
106
127
  end
107
- if step.key?(:order) && !step[:order].is_a?(Integer)
108
- raise ConfigurationError, "#{label} (#{model}): order: must be an Integer, got #{step[:order].inspect}"
128
+
129
+ step = priority_alias(step, label, model)
130
+ if step.key?(:priority) && !step[:priority].is_a?(Integer)
131
+ raise ConfigurationError, "#{label} (#{model}): priority: must be an Integer, got #{step[:priority].inspect}"
132
+ end
133
+ if step.key?(:rotate) && ![true, false].include?(step[:rotate])
134
+ raise ConfigurationError, "#{label} (#{model}): rotate: must be true or false, got #{step[:rotate].inspect}"
109
135
  end
110
136
 
111
137
  forward_rejected = step.fetch(:forward_rejected, true)
@@ -116,7 +142,24 @@ module Squishling
116
142
 
117
143
  params = step[:params] && Params.normalize(step[:params], "#{label} (#{model}) params")
118
144
  normalized = { model:, provider: step[:provider], params:, attempts:, forward_rejected: }
119
- step.key?(:order) ? normalized.merge(order: step[:order]) : normalized
145
+ normalized.merge(step.slice(:priority, :rotate, *extras))
146
+ end
147
+
148
+ # A step used on its own (a judge, a harness's step) can't take part in a tier, so it takes no priority: or
149
+ # rotate:. who names it for the message.
150
+ def single_step!(step, who)
151
+ return unless step.key?(:priority) || step.key?(:rotate)
152
+
153
+ raise ConfigurationError, "#{who} takes no priority: or rotate:"
154
+ end
155
+
156
+ # order: is the deprecated name of priority:.
157
+ def priority_alias(step, label, model)
158
+ return step unless step.key?(:order)
159
+ raise ConfigurationError, "#{label} (#{model}): give priority: or order:, not both" if step.key?(:priority)
160
+
161
+ warn "[Squishling] #{label} (#{model}): order: is deprecated, use priority:"
162
+ step.except(:order).merge(priority: step[:order])
120
163
  end
121
164
 
122
165
  def model_name(model, label)