squishling 0.2.0 → 0.3.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.
@@ -6,27 +6,49 @@ module Squishling
6
6
  DEFAULT_METHOD = :call
7
7
 
8
8
  # Set class-wide options in one call:
9
- # squishling model: "claude-sonnet-5-5", instructions: "...", output_schema: MySchema
9
+ # squishling model: "claude-sonnet-5-5", purpose: "...", output_schema: MySchema
10
10
  # model: is a single attempt; escalation: is a list of models tried in order (see ModelPath):
11
11
  # squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"]
12
12
  # Pass provider: alongside model: for models missing from RubyLLM's registry, e.g.
13
- # squishling model: "gpt-6-luna", provider: :openai
13
+ # squishling model: "gpt-7-preview", provider: :openai
14
+ # provider: needs a model: or escalation: (declared on the class), and a subclass's model never inherits its
15
+ # parent's provider.
14
16
  # params: are generation params merged over the configured defaults (see Configuration#default_params):
15
17
  # squishling params: { temperature: 0.1, top_p: 0.9 }
16
- # append_instructions: adds sections after the instructions (see #append_instructions):
17
- # squishling append_instructions: ["The Ruby that handles well-formed input:", self]
18
- def squishling(model: nil, escalation: nil, provider: nil, params: nil, instructions: nil,
19
- append_instructions: nil, output_schema: nil)
18
+ # append_to_purpose: adds sections after the purpose (see #append_to_purpose):
19
+ # squishling append_to_purpose: ["The Ruby that handles well-formed input:", self]
20
+ # harness: chooses how the escalation is used (see Harness):
21
+ # squishling harness: :judged_squishsum
22
+ # squawk: is called after every LLM attempt with the raw output (see Configuration#squawk); false silences
23
+ # an inherited one:
24
+ # squishling squawk: ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
25
+ def squishling(model: nil, escalation: nil, provider: nil, params: nil, harness: nil, purpose: nil,
26
+ append_to_purpose: nil, output_schema: nil, squawk: nil)
20
27
  path = ModelPath.declare(model, escalation, to_s)
28
+ # A provider belongs to the model declared at the same level. Without a model here it would apply to
29
+ # nothing (the model comes from the config or a parent class), so it is an error unless this class already
30
+ # declared its own.
31
+ if provider && !path && !instance_variable_defined?(:@squishling_model_path)
32
+ raise ConfigurationError, "#{self}: provider: needs a model: or escalation: declared on this class"
33
+ end
34
+
35
+ # Stored (even as nil) whenever a model is, so a subclass that declares its own model never inherits a
36
+ # provider meant for its parent's.
21
37
  @squishling_model_path = path if path
22
- @squishling_provider = provider if provider
38
+ @squishling_provider = provider if provider || path
23
39
  @squishling_params = Params.normalize(params, "#{self} params") if params
24
- self.instructions(instructions) if instructions
25
- self.append_instructions(append_instructions) unless append_instructions.nil?
40
+ @squishling_harness = Harness.normalize(harness, to_s) unless harness.nil?
41
+ self.purpose(purpose) if purpose
42
+ self.append_to_purpose(append_to_purpose) unless append_to_purpose.nil?
26
43
  self.output_schema(output_schema) if output_schema
44
+ @squishling_squawk = Squawk.validate(squawk, to_s) unless squawk.nil?
27
45
  self
28
46
  end
29
47
 
48
+ def squishling_squawk
49
+ squishling_lookup(:@squishling_squawk)
50
+ end
51
+
30
52
  # The class's (or nearest ancestor's) model or escalation, as normalized steps.
31
53
  def squishling_model_path
32
54
  squishling_lookup(:@squishling_model_path)
@@ -36,33 +58,37 @@ module Squishling
36
58
  squishling_lookup(:@squishling_provider)
37
59
  end
38
60
 
61
+ def squishling_harness
62
+ squishling_lookup(:@squishling_harness)
63
+ end
64
+
39
65
  # Generation params merged down the inheritance chain, so a subclass overrides individual keys.
40
66
  def squishling_params
41
67
  squishling_inherited(:squishling_params, {}).merge(@squishling_params || {})
42
68
  end
43
69
 
44
70
  # The system prompt. A String, or a Proc evaluated against the instance.
45
- def instructions(text = nil, &block)
46
- return squishling_lookup(:@squishling_instructions) if text.nil? && block.nil?
71
+ def purpose(text = nil, &block)
72
+ return squishling_lookup(:@squishling_purpose) if text.nil? && block.nil?
47
73
 
48
- @squishling_instructions = block || text
74
+ @squishling_purpose = block || text
49
75
  end
50
76
 
51
- # Sections appended to the system prompt after the instructions, added to by subclasses, `squish`, and
77
+ # Sections appended to the system prompt after the purpose, added to by subclasses, `squish`, and
52
78
  # `squish!`. Items: Strings; a class or module (`self` for this class) or a method (`instance_method(:call)`),
53
79
  # sent as its Ruby source; or a Proc evaluated against the instance. `false` drops inherited items.
54
- # append_instructions "Here is the Ruby that parses well-formed invoices:", self
55
- # append_instructions { "This client's invoices are in #{currency}." }
56
- def append_instructions(*items, &block)
80
+ # append_to_purpose "Here is the Ruby that parses well-formed invoices:", self
81
+ # append_to_purpose { "This client's invoices are in #{currency}." }
82
+ def append_to_purpose(*items, &block)
57
83
  items = items.first if items.size == 1 && items.first.is_a?(Array)
58
84
  items += [block] if block
59
- (@squishling_append_instructions ||= []).concat(Appendices.normalize(items, "#{self} append_instructions"))
85
+ (@squishling_append_to_purpose ||= []).concat(Appendices.normalize(items, "#{self} append_to_purpose"))
60
86
  self
61
87
  end
62
88
 
63
89
  # Every level's items in declaration order, `false` markers included (see Appendices.resolve).
64
- def squishling_append_instructions
65
- squishling_inherited(:squishling_append_instructions, []) + (@squishling_append_instructions || [])
90
+ def squishling_append_to_purpose
91
+ squishling_inherited(:squishling_append_to_purpose, []) + (@squishling_append_to_purpose || [])
66
92
  end
67
93
 
68
94
  # The output format: a Schematist::Schema subclass (RubyLLM::Schema with the ruby_llm-schema shim),
@@ -85,7 +111,7 @@ module Squishling
85
111
 
86
112
  # Called with the error and the method's inputs (as keywords) when the LLM path fails with an
87
113
  # InvalidOutputError or LLMError, evaluated against the instance. Its return value is used as
88
- # the result (hashes are validated and typed); re-raise to propagate.
114
+ # the result (validated against the schema and typed like a deterministic return); re-raise to propagate.
89
115
  # squish_fallback { |error, **inputs| { priority: "medium", team: "support" } }
90
116
  def squish_fallback(&block)
91
117
  @squishling_fallback = block
@@ -118,21 +144,26 @@ module Squishling
118
144
  end
119
145
 
120
146
  # Make methods elastic. Each may override the class-level settings:
121
- # squish :triage, instructions: "...", escalation: %w[claude-haiku-4-5 claude-sonnet-5-5], when: ->(**) { true },
122
- # fallback: ->(error, **) { { priority: "medium" } }, append_instructions: [...],
123
- # validate: ->(result, **) { "team is required" if result.team.empty? } do
147
+ # squish :triage, purpose: "...", escalation: %w[claude-haiku-4-5 claude-sonnet-5-5], when: ->(**) { true },
148
+ # fallback: ->(error, **) { { priority: "medium" } }, append_to_purpose: [...],
149
+ # validate: ->(result, **) { "team is required" if result.team.empty? },
150
+ # harness: :squishsum, squawk: ->(output:, error:, **) { Tracer.record(output, error) } do
124
151
  # string :priority
125
152
  # end
126
- def squish(*names, instructions: nil, append_instructions: nil, output_schema: nil, model: nil, escalation: nil,
127
- provider: nil, params: nil, when: nil, fallback: nil, validate: nil, &schema_block)
153
+ def squish(*names, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil, escalation: nil,
154
+ provider: nil, params: nil, harness: nil, when: nil, fallback: nil, validate: nil, squawk: nil, &schema_block)
128
155
  schema = schema_block ? Schematist::Schema.create(&schema_block) : output_schema
129
156
  model = ModelPath.declare(model, escalation, "#{self} squish")
157
+ raise ConfigurationError, "#{self} squish: provider: needs a model: or escalation:" if provider && !model
158
+
130
159
  params &&= Params.normalize(params, "#{self} squish params")
131
- unless append_instructions.nil?
132
- append_instructions = Appendices.normalize(append_instructions, "#{self} squish append_instructions")
160
+ harness = Harness.normalize(harness, "#{self} squish") unless harness.nil?
161
+ unless append_to_purpose.nil?
162
+ append_to_purpose = Appendices.normalize(append_to_purpose, "#{self} squish append_to_purpose")
133
163
  end
134
- options = { instructions:, append_instructions:, output_schema: schema, model:, provider:, params:,
135
- predicate: binding.local_variable_get(:when), fallback:, validator: validate }.compact
164
+ squawk = Squawk.validate(squawk, "#{self} squish")
165
+ options = { purpose:, append_to_purpose:, output_schema: schema, model:, provider:, params:, harness:,
166
+ predicate: binding.local_variable_get(:when), fallback:, validator: validate, squawk: }.compact
136
167
 
137
168
  names.map(&:to_sym).each do |name|
138
169
  (@squishling_methods ||= {})[name] = options
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # `include Squishling` puts its methods ahead of inherited ones, so a class that inherits a method of the same
5
+ # name (Sinatra::Base.call, a parent's own `purpose`, ...) would be silently shadowed. Methods the class
6
+ # defines itself always win, so they are never reported.
7
+ module Collisions
8
+ INSTANCE_METHODS = %i[squish! squishling_result].freeze
9
+
10
+ module_function
11
+
12
+ # Raises ConfigurationError naming every inherited method Squishling would override. Call it before
13
+ # extending the class with ClassMethods, and after the module is in the class's ancestors.
14
+ def check!(base)
15
+ found = class_collisions(base) + instance_collisions(base)
16
+ return if found.empty?
17
+
18
+ raise ConfigurationError,
19
+ "#{base} inherits methods that Squishling would override: #{found.join(', ')}. " \
20
+ "Include Squishling in a plain Ruby class instead (compose the framework object rather than inheriting it)"
21
+ end
22
+
23
+ def class_collisions(base)
24
+ ClassMethods.public_instance_methods(false).filter_map do |name|
25
+ next if name == :inherited || !base.respond_to?(name, true)
26
+
27
+ owner = base.method(name).owner
28
+ next if owner == base.singleton_class || owner == ClassMethods
29
+
30
+ "#{base}.#{name} (from #{describe(owner)})"
31
+ end
32
+ end
33
+
34
+ def instance_collisions(base)
35
+ ancestors = base.ancestors
36
+ inherited = ancestors.drop(ancestors.index(Squishling) + 1)
37
+
38
+ INSTANCE_METHODS.filter_map do |name|
39
+ next if defined_in?(base, name)
40
+
41
+ owner = inherited.find { |mod| defined_in?(mod, name) }
42
+ "#{base}##{name} (from #{describe(owner)})" if owner
43
+ end
44
+ end
45
+
46
+ def defined_in?(mod, name)
47
+ mod.method_defined?(name, false) || mod.private_method_defined?(name, false)
48
+ end
49
+
50
+ def describe(owner)
51
+ owner.singleton_class? ? "#{owner.attached_object}'s class methods" : owner.to_s
52
+ end
53
+ end
54
+ end
@@ -23,16 +23,27 @@ module Squishling
23
23
  # with_thinking; any other key (top_p, max_tokens, seed, ...) is passed to the provider as-is.
24
24
  attr_reader :default_params
25
25
 
26
+ # How every squishling class that doesn't declare its own harness uses its escalation (see Harness):
27
+ # :escalation (the default), :squishsum, :judged_squishsum, :ensemble, :judged_ensemble, or a Hash with
28
+ # type: and its options.
29
+ attr_reader :default_harness
30
+
26
31
  # Optional Logger for routing and escalation decisions.
27
32
  attr_accessor :logger
28
33
 
34
+ # Optional callable run after every LLM attempt with the raw output (see Squawk), e.g.
35
+ # ->(output:, metadata:, error:) { Tracer.record(output, metadata, error) }
36
+ attr_reader :squawk
37
+
29
38
  def initialize
30
39
  @default_model = nil
31
40
  @default_escalation = nil
32
41
  @default_model_path = nil
33
42
  @default_provider = nil
34
43
  @default_params = {}
44
+ @default_harness = nil
35
45
  @logger = nil
46
+ @squawk = nil
36
47
  end
37
48
 
38
49
  def default_model=(model)
@@ -47,10 +58,18 @@ module Squishling
47
58
  @default_escalation = escalation
48
59
  end
49
60
 
61
+ def squawk=(hook)
62
+ @squawk = Squawk.validate(hook, "Squishling.configure")
63
+ end
64
+
50
65
  def default_params=(params)
51
66
  @default_params = Params.normalize(params, "default_params")
52
67
  end
53
68
 
69
+ def default_harness=(harness)
70
+ @default_harness = harness.nil? ? nil : Harness.normalize(harness, "default_harness")
71
+ end
72
+
54
73
  MAX_RETRIES_REMOVED = "max_retries was removed; use default_escalation (or a class/method escalation:) " \
55
74
  "with attempts:, e.g. [{ model: \"claude-haiku-4-5\", attempts: 2 }]"
56
75
 
@@ -9,42 +9,47 @@ module Squishling
9
9
 
10
10
  attr_reader :klass, :name, :call_context
11
11
 
12
- def initialize(klass:, name:, instructions: nil, append_instructions: nil, output_schema: nil, model: nil,
13
- provider: nil, params: nil, predicate: nil, fallback: nil, validator: nil, call_context: {})
12
+ def initialize(klass:, name:, purpose: nil, append_to_purpose: nil, output_schema: nil, model: nil,
13
+ provider: nil, params: nil, harness: nil, predicate: nil, fallback: nil, validator: nil, squawk: nil,
14
+ call_context: {})
14
15
  @klass = klass
15
16
  @name = name
16
- @instructions = instructions
17
- @append_instructions = append_instructions
17
+ @purpose = purpose
18
+ @append_to_purpose = append_to_purpose
18
19
  @output_schema = output_schema
19
20
  @model = model
20
21
  @provider = provider
21
22
  @params = params
23
+ @harness = harness
22
24
  @predicate = predicate
23
25
  @fallback = fallback
24
26
  @validator = validator
27
+ @squawk = squawk
25
28
  @call_context = call_context
26
29
  end
27
30
 
28
31
  # This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
29
32
  # can't be overridden: the call must still return the method's result type.
30
- def for_call(instructions: nil, append_instructions: nil, context: nil, model: nil, escalation: nil,
31
- provider: nil, params: nil)
33
+ def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
34
+ provider: nil, params: nil, harness: nil)
32
35
  call_path = ModelPath.declare(model, escalation, "#{label} squish!")
33
36
  raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
34
37
  unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
35
38
  raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
36
39
  end
37
40
 
38
- appended = Appendices.normalize(append_instructions, "#{label} squish!") unless append_instructions.nil?
41
+ appended = Appendices.normalize(append_to_purpose, "#{label} squish!") unless append_to_purpose.nil?
39
42
  call_params = params && Params.normalize(params, "#{label} squish! params")
43
+ call_harness = Harness.normalize(harness, "#{label} squish!") unless harness.nil?
40
44
  self.class.new(
41
45
  klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
42
- validator: @validator,
43
- instructions: instructions || @instructions,
44
- append_instructions: [*@append_instructions, *appended],
46
+ validator: @validator, squawk: @squawk,
47
+ purpose: purpose || @purpose,
48
+ append_to_purpose: [*@append_to_purpose, *appended],
45
49
  model: call_path || @model, provider: call_path ? provider : @provider,
46
50
  # merge, not Params.resolve: a nil at the method level must still unset the class's key.
47
51
  params: call_params ? (@params || {}).merge(call_params) : @params,
52
+ harness: call_harness || @harness,
48
53
  call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
49
54
  )
50
55
  end
@@ -53,17 +58,17 @@ module Squishling
53
58
  "#{klass}##{name}"
54
59
  end
55
60
 
56
- # The system prompt: the instructions, then each append_instructions section.
57
- def instructions(receiver)
58
- value = @instructions || klass.instructions
61
+ # The system prompt: the purpose, then each append_to_purpose section.
62
+ def purpose(receiver)
63
+ value = @purpose || klass.purpose
59
64
  value = receiver.instance_exec(&value) if value.is_a?(Proc)
60
65
  return value if value.nil? || value.empty?
61
66
 
62
- [value, *Appendices.render(append_instructions, receiver, label)].join("\n\n")
67
+ [value, *Appendices.render(append_to_purpose, receiver, label)].join("\n\n")
63
68
  end
64
69
 
65
- def append_instructions
66
- Appendices.resolve(klass.squishling_append_instructions + (@append_instructions || []))
70
+ def append_to_purpose
71
+ Appendices.resolve(klass.squishling_append_to_purpose + (@append_to_purpose || []))
67
72
  end
68
73
 
69
74
  def schema
@@ -78,10 +83,15 @@ module Squishling
78
83
  config = Squishling.config
79
84
  entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
80
85
  [config.default_model_path, config.default_provider]].find(&:first)
81
- entries ||= [{ model: nil, attempts: 1 }]
86
+ entries ||= [{ model: nil, attempts: 1, forward_rejected: true }]
82
87
  ModelPath.steps(entries, provider:, params:)
83
88
  end
84
89
 
90
+ # How the escalation is used (see Harness): the method's, the class's, the configured default, or :escalation.
91
+ def harness
92
+ @harness || klass.squishling_harness || Squishling.config.default_harness || Harness::DEFAULT
93
+ end
94
+
85
95
  # Generation params: config defaults, overridden key by key by the class, then by the method.
86
96
  def params
87
97
  Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
@@ -92,6 +102,12 @@ module Squishling
92
102
  @validator || klass.squishling_validator
93
103
  end
94
104
 
105
+ # The observability hook: the method's, else the class's, else the configured one. `false` at a level
106
+ # silences the levels above it.
107
+ def squawk
108
+ [@squawk, klass.squishling_squawk, Squishling.config.squawk].find { |hook| !hook.nil? } || nil
109
+ end
110
+
95
111
  def context_names
96
112
  klass.squishling_context_names
97
113
  end
@@ -115,18 +131,20 @@ module Squishling
115
131
  coerce(receiver.instance_exec(e, **inputs, &handler))
116
132
  end
117
133
 
118
- # Deterministic return values: hashes are validated and turned into the typed result;
119
- # anything else (including an already-built result) passes through.
134
+ # Deterministic and fallback return values: with an output_schema, every value is validated and
135
+ # typed like an LLM result. Without one, the value passes through untouched.
120
136
  def coerce(value)
121
- value.is_a?(Hash) && schema ? build_result(value, squished: false) : value
137
+ schema ? build_result(value, squished: false) : value
122
138
  end
123
139
 
140
+ # A result of this schema's own class passes through; any other result is re-validated via to_h.
124
141
  def build_result(attrs, squished:)
125
142
  raise ConfigurationError, "#{label} has no output_schema" unless schema
126
- return attrs if attrs.is_a?(Result::Instance)
143
+ return attrs if schema.result_class && attrs.is_a?(schema.result_class)
127
144
 
128
- data = Schema.jsonify(attrs)
129
- errors = schema.validate(data)
145
+ attrs = attrs.to_h if attrs.is_a?(Result::Instance)
146
+ data, errors = jsonify_return(attrs)
147
+ errors = schema.validate(data) if errors.empty?
130
148
  raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
131
149
 
132
150
  schema.build(data, squished:)
@@ -162,6 +180,13 @@ module Squishling
162
180
 
163
181
  private
164
182
 
183
+ # Values JSON can't represent (NaN, Infinity, cycles) are invalid output, not a raw JSON error.
184
+ def jsonify_return(attrs)
185
+ [Schema.jsonify(attrs), []]
186
+ rescue JSON::GeneratorError, JSON::NestingError => e
187
+ [nil, [e.message]]
188
+ end
189
+
165
190
  # The implementation's parameters, beneath the prepended wrappers.
166
191
  def parameters
167
192
  Wrapper.implementation(klass.instance_method(name))&.parameters || []
@@ -3,7 +3,7 @@
3
3
  module Squishling
4
4
  class Error < StandardError; end
5
5
 
6
- # A programming or setup mistake (missing instructions/schema, non-strict schema, unknown model,
6
+ # A programming or setup mistake (missing purpose/schema, non-strict schema, unknown model,
7
7
  # missing API key). Never retried and never passed to squish_fallback.
8
8
  class ConfigurationError < Error; end
9
9
 
@@ -23,6 +23,27 @@ module Squishling
23
23
  end
24
24
  end
25
25
 
26
+ # A sampling harness's samples (squishsum or ensemble) were valid but didn't agree, and either no judge was declared
27
+ # (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates holds the two
28
+ # typed results, so a squish_fallback can still return one of them, and raw both as Hashes. models names the
29
+ # model behind each role (sample a, sample b, then the judge if one ran), not one entry per attempt; attempts
30
+ # is nil.
31
+ class DisagreementError < InvalidOutputError
32
+ attr_reader :candidates, :verdict, :reason
33
+
34
+ # detail: is text Squishling wrote itself (a judgment model's choice and probability) and is added to the
35
+ # message. A chat judge's reason is model-written and can quote the input, so it stays out of the message
36
+ # (and the logs); read it from #reason.
37
+ def initialize(candidates:, verdict: nil, reason: nil, detail: nil, models: nil)
38
+ @candidates = candidates
39
+ @verdict = verdict
40
+ @reason = reason
41
+ error = verdict ? "the judge rejected both samples" : "the two samples disagreed"
42
+ error += " (#{detail})" if detail
43
+ super(errors: [error], raw: candidates.map(&:to_h), models:)
44
+ end
45
+ end
46
+
26
47
  # The LLM call itself failed (rate limit, server error, timeout, connection) after RubyLLM's own
27
48
  # HTTP retries. The original exception is available as #cause.
28
49
  class LLMError < Error; end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ # How a squished call uses its escalation. Declared as a type, or a Hash with the type and its options:
5
+ # harness: :squishsum
6
+ # harness: { type: :judged_squishsum, judge: "claude-opus-5-5", compare: ->(a, b, **) { a.team == b.team } }
7
+ # :escalation (the default) tries each attempt in order until an output passes. :squishsum asks the first
8
+ # escalation step twice, concurrently, and accepts the output only when both samples agree. :ensemble asks
9
+ # the first and second escalation steps once each instead (a cross-model check). :judged_squishsum and
10
+ # :judged_ensemble hand disagreeing samples to a judge that picks one or rejects both.
11
+ class Harness
12
+ TYPES = %i[escalation squishsum judged_squishsum ensemble judged_ensemble].freeze
13
+ ENSEMBLE_TYPES = %i[ensemble judged_ensemble].freeze
14
+ JUDGED_TYPES = %i[judged_squishsum judged_ensemble].freeze
15
+ KEYS = %i[type judge judge_instructions compare].freeze
16
+
17
+ # A judge is one step: a model name or a Hash with the escalation step keys (except order:), plus
18
+ # type: (:chat, the default, or :judgment for a System One decision model through RubyLLM.judge) and,
19
+ # for :judgment, min_confidence: (the probability the chosen candidate needs).
20
+ JUDGE_TYPES = %i[chat judgment].freeze
21
+ JUDGE_KEYS = %i[type min_confidence].freeze
22
+ # Request keys RubyLLM's judgment protocols own (model and input are already reserved params).
23
+ JUDGMENT_RESERVED_PARAMS = %i[state questions].freeze
24
+ DEFAULT_MIN_CONFIDENCE = 0.8
25
+
26
+ DEFAULT_JUDGE_INSTRUCTIONS = <<~TEXT.strip
27
+ You are an impartial judge. Two independent attempts at the same operation returned different outputs,
28
+ candidate "a" and candidate "b". Both already match the required output format, so judge only their
29
+ content. You are given the operation's purpose and its input. Decide which candidate correctly and
30
+ faithfully carries out the purpose for this input. If both do (for example, they differ only in
31
+ wording), choose either one, preferring "a". Choose "neither" only when both are wrong or you can't tell
32
+ whether either is correct. Never combine them or invent a third answer.
33
+ TEXT
34
+
35
+ attr_reader :type, :judge, :judge_instructions, :compare
36
+
37
+ class << self
38
+ def normalize(value, label)
39
+ case value
40
+ when Symbol, String then new(type: type_for(value, label))
41
+ when Hash then from_hash(value, label)
42
+ else
43
+ raise ConfigurationError, "#{label}: harness must be one of #{TYPES.join(', ')} or a Hash with type:, " \
44
+ "got #{value.inspect}"
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def from_hash(hash, label)
51
+ hash = hash.transform_keys(&:to_sym)
52
+ unknown = hash.keys - KEYS
53
+ raise ConfigurationError, "#{label}: unknown harness option(s) #{unknown.join(', ')}" if unknown.any?
54
+ raise ConfigurationError, "#{label}: a harness Hash needs type:" unless hash.key?(:type)
55
+
56
+ type = type_for(hash[:type], label)
57
+ check_options!(type, hash, label)
58
+ new(type:, judge: hash[:judge].nil? ? nil : normalize_judge(hash[:judge], label),
59
+ judge_instructions: hash[:judge_instructions], compare: hash[:compare])
60
+ end
61
+
62
+ def type_for(value, label)
63
+ type = value.to_s.to_sym if value.is_a?(Symbol) || value.is_a?(String)
64
+ return type if TYPES.include?(type)
65
+
66
+ raise ConfigurationError, "#{label}: unknown harness #{value.inspect} (use #{TYPES.join(', ')})"
67
+ end
68
+
69
+ def check_options!(type, hash, label)
70
+ judge_options = %i[judge judge_instructions].select { |key| hash.key?(key) }
71
+ if judge_options.any? && !JUDGED_TYPES.include?(type)
72
+ options = judge_options.map { |key| "#{key}:" }.join(", ")
73
+ raise ConfigurationError,
74
+ "#{label}: #{options} can only be used with the #{JUDGED_TYPES.join(' and ')} harnesses"
75
+ end
76
+ if hash.key?(:compare) && type == :escalation
77
+ raise ConfigurationError, "#{label}: compare: only applies to the sampling harnesses (squishsum and ensemble)"
78
+ end
79
+ unless hash[:compare].nil? || hash[:compare].is_a?(Proc)
80
+ raise ConfigurationError, "#{label}: compare: must be a Proc, got #{hash[:compare].class}"
81
+ end
82
+
83
+ instructions = hash[:judge_instructions]
84
+ return if instructions.nil? || instructions.is_a?(Proc)
85
+ return if instructions.is_a?(String) && !instructions.strip.empty?
86
+
87
+ raise ConfigurationError, "#{label}: judge_instructions: must be a non-blank String or a Proc"
88
+ end
89
+
90
+ # The judge as a normalized escalation step (see ModelPath), plus its type and min_confidence.
91
+ def normalize_judge(judge, label)
92
+ judge_label = "#{label} judge"
93
+ return ModelPath.normalize_step(judge, judge_label).merge(type: :chat) unless judge.is_a?(Hash)
94
+
95
+ judge = judge.transform_keys(&:to_sym)
96
+ raise ConfigurationError, "#{judge_label}: a judge is one step, so it takes no order:" if judge.key?(:order)
97
+
98
+ type = judge.fetch(:type, :chat)
99
+ type = type.to_sym if type.is_a?(String)
100
+ unless JUDGE_TYPES.include?(type)
101
+ raise ConfigurationError,
102
+ "#{judge_label}: type: must be one of #{JUDGE_TYPES.join(', ')}, got #{type.inspect}"
103
+ end
104
+
105
+ if type == :chat && judge.key?(:min_confidence)
106
+ raise ConfigurationError, "#{judge_label}: min_confidence: only applies to type: :judgment judges"
107
+ end
108
+
109
+ step = ModelPath.normalize_step(judge.except(*JUDGE_KEYS), judge_label).merge(type:)
110
+ return step unless type == :judgment
111
+
112
+ reserved = (step[:params] || {}).keys & JUDGMENT_RESERVED_PARAMS
113
+ if reserved.any?
114
+ raise ConfigurationError, "#{judge_label}: #{reserved.join(', ')} can't be set through params " \
115
+ "(controlled by Squishling/RubyLLM)"
116
+ end
117
+
118
+ step.merge(min_confidence: min_confidence(judge, judge_label))
119
+ end
120
+
121
+ def min_confidence(judge, label)
122
+ value = judge.fetch(:min_confidence, DEFAULT_MIN_CONFIDENCE)
123
+ return value.to_f if value.is_a?(Numeric) && value.between?(0, 1)
124
+
125
+ raise ConfigurationError, "#{label}: min_confidence: must be a number from 0 to 1, got #{value.inspect}"
126
+ end
127
+ end
128
+
129
+ def initialize(type:, judge: nil, judge_instructions: nil, compare: nil)
130
+ @type = type
131
+ @judge = judge
132
+ @judge_instructions = judge_instructions
133
+ @compare = compare
134
+ freeze
135
+ end
136
+
137
+ DEFAULT = new(type: :escalation)
138
+
139
+ # Whether the call asks two samples (squishsum and ensemble harnesses) rather than walking the escalation.
140
+ def squishsum?
141
+ type != :escalation
142
+ end
143
+
144
+ # Whether the two samples come from the first two escalation steps instead of both from the first.
145
+ def ensemble?
146
+ ENSEMBLE_TYPES.include?(type)
147
+ end
148
+
149
+ def judged?
150
+ JUDGED_TYPES.include?(type)
151
+ end
152
+ end
153
+ end