squishling 0.3.0 → 0.5.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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +63 -15
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +89 -24
  6. data/docs/coolhand.md +66 -0
  7. data/docs/failures.md +129 -20
  8. data/docs/harnesses.md +186 -48
  9. data/docs/measuring-tokens.md +36 -42
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +7 -2
  12. data/docs/schemas.md +33 -6
  13. data/docs/testing.md +183 -0
  14. data/docs/verifying.md +84 -0
  15. data/lib/squishling/adapter/error_body.rb +47 -0
  16. data/lib/squishling/adapter.rb +275 -0
  17. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  18. data/lib/squishling/adapters/openai_options.rb +72 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +72 -8
  24. data/lib/squishling/definition.rb +95 -17
  25. data/lib/squishling/errors.rb +97 -12
  26. data/lib/squishling/harness/base.rb +136 -0
  27. data/lib/squishling/harness/escalation.rb +22 -0
  28. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  29. data/lib/squishling/harness/option_types.rb +94 -0
  30. data/lib/squishling/harness/registry.rb +41 -0
  31. data/lib/squishling/harness/squishsum.rb +22 -0
  32. data/lib/squishling/harness.rb +25 -99
  33. data/lib/squishling/invoker.rb +28 -233
  34. data/lib/squishling/judge.rb +78 -21
  35. data/lib/squishling/llm_client.rb +73 -109
  36. data/lib/squishling/model_path.rb +117 -27
  37. data/lib/squishling/named_registry.rb +64 -0
  38. data/lib/squishling/observer.rb +109 -0
  39. data/lib/squishling/observers/coolhand.rb +86 -0
  40. data/lib/squishling/output_check.rb +13 -9
  41. data/lib/squishling/params.rb +15 -27
  42. data/lib/squishling/railtie.rb +10 -0
  43. data/lib/squishling/result.rb +37 -16
  44. data/lib/squishling/rotation.rb +31 -0
  45. data/lib/squishling/rspec.rb +18 -0
  46. data/lib/squishling/run/messages.rb +79 -0
  47. data/lib/squishling/run/rejections.rb +43 -0
  48. data/lib/squishling/run.rb +320 -0
  49. data/lib/squishling/schema.rb +10 -9
  50. data/lib/squishling/squawk.rb +33 -3
  51. data/lib/squishling/tasks/squishling.rake +18 -0
  52. data/lib/squishling/testing/adapter.rb +194 -0
  53. data/lib/squishling/testing/checks.rb +39 -0
  54. data/lib/squishling/testing/direct.rb +41 -0
  55. data/lib/squishling/testing/errors.rb +18 -0
  56. data/lib/squishling/testing/fixtures.rb +206 -0
  57. data/lib/squishling/testing/helpers.rb +71 -0
  58. data/lib/squishling/testing/minitest.rb +32 -0
  59. data/lib/squishling/testing/observer.rb +31 -0
  60. data/lib/squishling/testing/reply.rb +60 -0
  61. data/lib/squishling/testing/request.rb +75 -0
  62. data/lib/squishling/testing/rspec.rb +30 -0
  63. data/lib/squishling/testing/script.rb +189 -0
  64. data/lib/squishling/testing/state.rb +73 -0
  65. data/lib/squishling/testing/stubs.rb +112 -0
  66. data/lib/squishling/testing.rb +126 -0
  67. data/lib/squishling/verification.rb +39 -0
  68. data/lib/squishling/verifier.rb +179 -0
  69. data/lib/squishling/version.rb +1 -1
  70. data/lib/squishling.rb +89 -4
  71. metadata +53 -21
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Squishling
4
- # The elastic path: sends a squished method's inputs through RubyLLM with structured output.
4
+ # The elastic path: sends a squished method's inputs through the configured adapter with structured output.
5
5
  class Invoker
6
6
  INPUT_NOTE = <<~NOTE
7
7
  The input is a JSON object. "arguments" holds the values passed to this operation; "context", when
@@ -15,192 +15,50 @@ 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).
30
- def call
31
- purpose = @definition.purpose(@receiver)
32
- schema = @definition.schema
33
- raise ConfigurationError, "#{@definition.label} has no purpose" if purpose.nil? || purpose.empty?
34
- raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
24
+ # Everything a Run needs of the call: the prompt, the payload it was built from, and the purpose text.
25
+ Built = Data.define(:prompt, :payload, :purpose)
35
26
 
27
+ # Runs the harness (Definition#harness) over a Run of the call: the harness decides how the escalation is
28
+ # used, and what it returns is checked against the output schema's type.
29
+ def call
30
+ built = build
36
31
  harness = @definition.harness
37
- path = @definition.escalation_path
38
- body = payload
39
- prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:,
40
- 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)
50
- end
51
-
52
- private
53
-
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
32
+ run = Run.new(definition: @definition, receiver: @receiver, inputs: @inputs, prompt: built.prompt,
33
+ payload: built.payload, purpose: built.purpose, harness:)
34
+ accepted(harness.klass.new.call(run, **harness.options), run, harness)
147
35
  end
148
36
 
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
171
-
172
- sample.rejected = [response.content, errors]
173
- log_failure(role, attempt, steps, errors.join("; "))
37
+ # The first request the call would make (system prompt, output schema, user message), without making it.
38
+ def prompt
39
+ build.prompt
174
40
  end
175
41
 
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
42
+ private
186
43
 
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
44
+ def build
45
+ purpose = @definition.purpose(@receiver)
46
+ schema = @definition.schema
47
+ raise ConfigurationError, "#{@definition.label} has no purpose" if purpose.nil? || purpose.empty?
48
+ raise ConfigurationError, "#{@definition.label} has no output_schema" unless schema
190
49
 
191
- raise DisagreementError.new(candidates: [first, second], verdict: :neither, reason:, detail:,
192
- models: models + [judge.step.model])
50
+ body = payload
51
+ prompt = Prompt.new(instructions: "#{purpose}\n\n#{INPUT_NOTE}", schema:, input: JSON.generate(body), role: nil)
52
+ Built.new(prompt:, payload: body, purpose:)
193
53
  end
194
54
 
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
55
+ # What a harness returns must be a result its run produced: validated output, typed like the deterministic
56
+ # path's. One built by hand, or a hash, would skip the schema and squish_validate.
57
+ def accepted(value, run, harness)
58
+ return value if run.produced?(value)
198
59
 
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)
60
+ raise ConfigurationError, "#{@definition.label}: the #{harness.type} harness must return a result from " \
61
+ "its run (escalate, samples, or a judge's pick), got #{value.class}"
204
62
  end
205
63
 
206
64
  # Only the arguments, the declared squish_context names, and a squish! call's context leave the process.
@@ -233,68 +91,5 @@ module Squishling
233
91
 
234
92
  @receiver.instance_variable_get(:"@#{name}")
235
93
  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
94
  end
300
95
  end
@@ -1,12 +1,20 @@
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
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
+ # the adapter's judgments (RubyLLM's, with the RubyLLM adapter), and its pick counts only at or above
8
8
  # min_confidence.
9
9
  class Judge
10
+ # The judge kinds: :chat (a chat model answering VERDICT_SCHEMA) and :judgment (a System One decision model
11
+ # through the adapter's judgments). A judge is an escalation step (see ModelPath) plus these keys:
12
+ # type: (default :chat)
13
+ # and, for :judgment, min_confidence: (the probability the chosen candidate needs).
14
+ TYPES = %i[chat judgment].freeze
15
+ STEP_KEYS = %i[type min_confidence].freeze
16
+ DEFAULT_MIN_CONFIDENCE = 0.8
17
+
10
18
  VERDICT_SCHEMA = {
11
19
  "type" => "object",
12
20
  "properties" => {
@@ -29,16 +37,29 @@ module Squishling
29
37
  neither: "Neither candidate is clearly correct"
30
38
  }.freeze
31
39
 
32
- def initialize(definition:, receiver:, client:, harness:, rest:, purpose:, payload:, escalate:, log_failure:)
40
+ # What a judge decided: :a, :b, or :neither, with its reason. The detail is text Squishling wrote (nil for a
41
+ # chat judge, whose reason is model-written) and is safe for error messages.
42
+ Verdict = Data.define(:choice, :reason, :detail)
43
+
44
+ # judge: is a normalized judge step (see Harness::JudgedSquishsum) or nil for the escalation's next stage,
45
+ # instructions: a String, a Proc evaluated against the receiver, or nil for the default prompt.
46
+ def initialize(definition:, receiver:, client:, judge:, instructions:, path:, purpose:, payload:, escalate:,
47
+ log_failure:, guard:, budget_check:, harness_type:)
33
48
  @definition = definition
34
49
  @receiver = receiver
35
50
  @client = client
36
- @harness = harness
51
+ @judge = judge
52
+ @instructions = instructions
53
+ check!
54
+ check_judgments!
55
+ @guard = guard
56
+ @budget_check = budget_check
57
+ @harness_type = harness_type
37
58
  @purpose = purpose
38
59
  @payload = payload
39
60
  @escalate = escalate
40
61
  @log_failure = log_failure
41
- @steps = resolve_steps(rest)
62
+ @steps = resolve_steps(path)
42
63
  end
43
64
 
44
65
  # The judge's first attempt, for logs and DisagreementError#models.
@@ -46,31 +67,66 @@ module Squishling
46
67
  @steps.first
47
68
  end
48
69
 
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.
70
+ # The Verdict for the two candidates: :a or :b for the chosen one, or :neither.
51
71
  def verdict(first, second)
72
+ @guard.call
52
73
  candidates = { a: Schema.jsonify(first.to_h), b: Schema.jsonify(second.to_h) }
53
74
  judgment? ? judgment(candidates) : chat(candidates)
54
75
  end
55
76
 
56
77
  private
57
78
 
79
+ # What Run#judge was given. A harness declaration is checked earlier, with its own messages.
80
+ def check!
81
+ label = "#{@definition.label}: judge"
82
+ if @judge
83
+ type = @judge.fetch(:type)
84
+ unless TYPES.include?(type)
85
+ raise ConfigurationError, "#{label} type: must be one of #{TYPES.join(', ')}, got #{type.inspect}"
86
+ end
87
+
88
+ if type == :chat && @judge.key?(:min_confidence)
89
+ raise ConfigurationError, "#{label} min_confidence: only applies to type: :judgment judges"
90
+ end
91
+
92
+ minimum = @judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
93
+ unless minimum.is_a?(Numeric) && minimum.between?(0, 1)
94
+ raise ConfigurationError, "#{label} min_confidence: must be a number from 0 to 1, got #{minimum.inspect}"
95
+ end
96
+ end
97
+ return if @instructions.nil? || @instructions.is_a?(Proc) || non_blank_string?(@instructions)
98
+
99
+ raise ConfigurationError, "#{label} instructions must be a non-blank String or a Proc"
100
+ end
101
+
102
+ # Raised before any request, like the rest of the judge's checks.
103
+ def check_judgments!
104
+ return if !judgment? || @client.judgments?
105
+
106
+ raise ConfigurationError, "#{@definition.label}: judge type: :judgment needs an adapter that supports " \
107
+ "judgments (the configured adapter doesn't); use type: :chat or the :ruby_llm adapter"
108
+ end
109
+
110
+ def non_blank_string?(value)
111
+ value.is_a?(String) && !value.strip.empty?
112
+ end
113
+
58
114
  def judgment?
59
- @harness.judge&.fetch(:type) == :judgment
115
+ @judge&.fetch(:type) == :judgment
60
116
  end
61
117
 
62
118
  # A declared judge brings its own model and provider. A chat judge gets the method's generation params
63
119
  # 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)
120
+ def resolve_steps(path)
121
+ if (judge = @judge)
66
122
  params = judge[:type] == :judgment ? {} : @definition.params
67
123
  return ModelPath.steps([judge], provider: nil, params:)
68
124
  end
69
125
 
126
+ rest = path.drop_while { |step| step == path.first }
70
127
  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"
128
+ raise ConfigurationError, "#{@definition.label}: the #{@harness_type} harness needs a judge: or a second " \
129
+ "escalation step to judge with"
74
130
  end
75
131
 
76
132
  rest.take_while { |step| step == rest.first }
@@ -83,7 +139,7 @@ module Squishling
83
139
  schema: Schema.for(VERDICT_SCHEMA), input: JSON.generate(state(candidates)), role: :judge
84
140
  )
85
141
  result = @escalate.call(@steps, prompt)
86
- [result[:verdict].to_sym, result[:reason], nil]
142
+ Verdict.new(choice: result[:verdict].to_sym, reason: result[:reason], detail: nil)
87
143
  end
88
144
 
89
145
  def judgment(candidates)
@@ -100,12 +156,12 @@ module Squishling
100
156
  # The threshold is checked on the exact probability; it's rounded only for the reason text.
101
157
  def interpret(choice, probabilities)
102
158
  probability = probabilities.to_h.find { |key, _| key.to_s == choice.to_s }&.last.to_f
103
- minimum = @harness.judge[:min_confidence]
159
+ minimum = @judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
104
160
  shown = probability.round(3)
105
161
  picked = %i[a b].include?(choice)
106
162
  if picked && probability >= minimum
107
163
  text = "chosen with probability #{shown}"
108
- return [choice, text, text]
164
+ return Verdict.new(choice:, reason: text, detail: text)
109
165
  end
110
166
 
111
167
  text =
@@ -113,13 +169,14 @@ module Squishling
113
169
  elsif picked then "the judge chose #{choice} with probability #{shown}, below min_confidence #{minimum}"
114
170
  else "the judge returned an unrecognized choice (probability #{shown})"
115
171
  end
116
- [:neither, text, text]
172
+ Verdict.new(choice: :neither, reason: text, detail: text)
117
173
  end
118
174
 
119
175
  # A judgment always matches its questions, so only a failed request (LLMError) moves on to the next attempt.
120
176
  def judge_with_retries(input, questions)
121
177
  @steps.each.with_index(1) do |step, attempt|
122
- return @client.judge(input, questions:, step:)
178
+ @budget_check.call(attempts: attempt - 1)
179
+ return @client.observed_judgment(input, questions:, step:, attempt:, attempts: @steps.size)
123
180
  rescue LLMError => e
124
181
  raise if attempt == @steps.size
125
182
 
@@ -132,7 +189,7 @@ module Squishling
132
189
  end
133
190
 
134
191
  def judge_instructions
135
- value = @harness.judge_instructions || Harness::DEFAULT_JUDGE_INSTRUCTIONS
192
+ value = @instructions || Harness::DEFAULT_JUDGE_INSTRUCTIONS
136
193
  value = @receiver.instance_exec(&value) if value.is_a?(Proc)
137
194
  return value if value.is_a?(String) && !value.strip.empty?
138
195