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.
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Harness
5
+ # The base of every harness: the built-ins, and your own. Subclass it, declare the options it takes, define
6
+ # call, and register it (Squishling.register_harness):
7
+ #
8
+ # class Tiebreaker < Squishling::Harness::Base
9
+ # option :tiebreaker, :step, required: true
10
+ #
11
+ # def call(run, tiebreaker:)
12
+ # first, second = run.samples(run.stages.first(2))
13
+ # run.agree?(first, second) ? first : run.escalate(tiebreaker)
14
+ # end
15
+ # end
16
+ # Squishling.register_harness(:tiebreaker, Tiebreaker)
17
+ #
18
+ # call gets a Run (the primitives that ask for samples, escalate, and judge) and the validated options as
19
+ # keywords, and returns the accepted typed result, or raises Squishling::InvalidOutputError (a
20
+ # DisagreementError when samples disagreed) or Squishling::LLMError, which squish_fallback handles.
21
+ class Base
22
+ Option = Data.define(:name, :type, :required)
23
+
24
+ # The kinds of parameter that can receive the run, call's first argument.
25
+ RUN_PARAMETERS = %i[req opt rest].freeze
26
+ KEYWORD_PARAMETERS = %i[key keyreq].freeze
27
+ REQUIRED_PARAMETERS = %i[req keyreq].freeze
28
+
29
+ class << self
30
+ # Declares an option: its name, one of OptionTypes::TYPES (or a callable that normalizes the value, see
31
+ # OptionTypes), and whether it must be given. A harness is declared with { type: :name, option_name: value },
32
+ # and the options are checked up front.
33
+ def option(name, type, required: false)
34
+ unless OptionTypes.type?(type)
35
+ raise ConfigurationError, "option #{name.inspect}: type must be one of #{OptionTypes::TYPES.join(', ')} " \
36
+ "or a callable taking (value, label, name), got #{type.inspect}"
37
+ end
38
+
39
+ raise ConfigurationError, "declare options on a subclass of Harness::Base, not on Base" if equal?(Base)
40
+ if name.to_sym == :type
41
+ raise ConfigurationError, "option :type is taken: it names the harness in a harness Hash"
42
+ end
43
+
44
+ own_options[name.to_sym] = Option.new(name: name.to_sym, type:, required: required ? true : false)
45
+ end
46
+
47
+ # The declared options by name, including those of the superclass.
48
+ def options
49
+ inherited = equal?(Base) ? {} : superclass.options
50
+ inherited.merge(own_options)
51
+ end
52
+
53
+ # The options as given in a harness Hash (without type:): checked and normalized, or ConfigurationError.
54
+ # An option given as nil counts as not given.
55
+ def normalize_options(given, label)
56
+ given = given.transform_keys(&:to_sym)
57
+ unknown = given.keys - options.keys
58
+ raise ConfigurationError, "#{label}: unknown harness option(s) #{unknown.join(', ')}" if unknown.any?
59
+
60
+ given = given.compact
61
+
62
+ missing = options.values.select { |option| option.required && !given.key?(option.name) }
63
+ if missing.any?
64
+ raise ConfigurationError, "#{label}: harness option(s) #{missing.map(&:name).join(', ')} required"
65
+ end
66
+
67
+ normalized = given.to_h do |name, value|
68
+ [name, OptionTypes.check(options.fetch(name).type, value, label, name)]
69
+ end
70
+ validate_options!(normalized, label)
71
+ normalized.freeze
72
+ end
73
+
74
+ # Override to check options against each other; raise ConfigurationError (with the label) to reject.
75
+ def validate_options!(_options, _label); end
76
+
77
+ # Rejects a class that can't run as the harness registered under name: not a Base subclass, no call,
78
+ # or a call that doesn't take the run and each declared option.
79
+ def check_registrable!(klass, name)
80
+ unless klass.is_a?(Class) && klass <= Base && klass != Base
81
+ raise ConfigurationError, "harness #{name.inspect} must be a subclass of Squishling::Harness::Base, " \
82
+ "got #{klass.inspect}"
83
+ end
84
+ if klass.instance_method(:call).owner == Base
85
+ raise ConfigurationError, "harness #{name.inspect} must define call(run, **options)"
86
+ end
87
+ unless klass.public_method_defined?(:call)
88
+ raise ConfigurationError, "harness #{name.inspect}: call must be public"
89
+ end
90
+
91
+ check_signature!(klass, name)
92
+ end
93
+
94
+ private
95
+
96
+ def own_options
97
+ @own_options ||= {}
98
+ end
99
+
100
+ def check_signature!(klass, name)
101
+ parameters = klass.instance_method(:call).parameters
102
+ unless RUN_PARAMETERS.include?(parameters.first&.first)
103
+ raise ConfigurationError, "harness #{name.inspect}: call must take the run as its first argument"
104
+ end
105
+ if parameters.count { |type, _| type == :req } > 1
106
+ raise ConfigurationError, "harness #{name.inspect}: call takes the run, then options as keywords only"
107
+ end
108
+ if klass.instance_method(:initialize).parameters.any? { |type, _| REQUIRED_PARAMETERS.include?(type) }
109
+ raise ConfigurationError, "harness #{name.inspect}: a harness is created without arguments, " \
110
+ "so initialize can't require any"
111
+ end
112
+
113
+ accepted = parameters.filter_map { |type, key| key if KEYWORD_PARAMETERS.include?(type) }
114
+ required = parameters.filter_map { |type, key| key if type == :keyreq }
115
+ # A ** takes every declared option, but not an option's absence.
116
+ missing = parameters.any? { |type, _| type == :keyrest } ? [] : klass.options.keys - accepted
117
+ undeclared = required - klass.options.keys
118
+ # An option that may be left out can't be a required keyword.
119
+ optional = required & klass.options.values.reject(&:required).map(&:name)
120
+ return if missing.empty? && undeclared.empty? && optional.empty?
121
+
122
+ problems = [("doesn't take #{missing.join(', ')}" if missing.any?),
123
+ ("requires undeclared #{undeclared.join(', ')}" if undeclared.any?),
124
+ ("requires optional #{optional.join(', ')} (give it a default)" if optional.any?)].compact
125
+ raise ConfigurationError, "harness #{name.inspect}: call #{problems.join(' and ')} " \
126
+ "(declare each option with option, and take it as a keyword)"
127
+ end
128
+ end
129
+
130
+ # The harness itself; subclasses define it.
131
+ def call(_run, **)
132
+ raise ConfigurationError, "#{self.class} must define call(run, **options)"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Harness
5
+ # The default: each attempt of the escalation, in order, until an output passes the schema and squish_validate.
6
+ class Escalation < Base
7
+ def self.normalize_options(given, label)
8
+ if given.transform_keys(&:to_sym).key?(:compare)
9
+ raise ConfigurationError, "#{label}: compare: only applies to the squishsum harnesses"
10
+ end
11
+
12
+ super
13
+ end
14
+
15
+ def call(run)
16
+ run.escalate
17
+ end
18
+ end
19
+
20
+ Registry.register_built_in(:escalation, Escalation)
21
+ end
22
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Harness
5
+ # Like Squishling::Harness::Squishsum, but samples that disagree go to a judge, which picks one or rejects both.
6
+ # A judge is one step: a model name or a Hash with the escalation step keys (except priority:), plus
7
+ # type: (:chat, the default, or :judgment for a System One decision model through RubyLLM.judge) and,
8
+ # for :judgment, min_confidence: (the probability the chosen candidate needs).
9
+ class JudgedSquishsum < Base
10
+ # Request keys RubyLLM's judgment protocols own (model and input are already reserved params).
11
+ JUDGMENT_RESERVED_PARAMS = %i[state questions].freeze
12
+ private_constant :JUDGMENT_RESERVED_PARAMS
13
+
14
+ class << self
15
+ # A judge as a normalized escalation step (see ModelPath), plus its type and min_confidence. label is the
16
+ # declaration site and name the option it came from. Internal.
17
+ def normalize_judge(judge, label, name = :judge)
18
+ judge_label = "#{label} #{name}"
19
+ return ModelPath.normalize_step(judge, judge_label).merge(type: :chat) unless judge.is_a?(Hash)
20
+
21
+ judge = judge.transform_keys(&:to_sym)
22
+ type = judge.fetch(:type, :chat)
23
+ type = type.to_sym if type.is_a?(String)
24
+ unless Judge::TYPES.include?(type)
25
+ raise ConfigurationError,
26
+ "#{judge_label}: type: must be one of #{Judge::TYPES.join(', ')}, got #{type.inspect}"
27
+ end
28
+
29
+ if type == :chat && judge.key?(:min_confidence)
30
+ raise ConfigurationError, "#{judge_label}: min_confidence: only applies to type: :judgment judges"
31
+ end
32
+
33
+ step = ModelPath.normalize_step(judge.except(*Judge::STEP_KEYS), judge_label).merge(type:)
34
+ ModelPath.single_step!(step, "#{judge_label}: a judge is one step, so it")
35
+ return step unless type == :judgment
36
+
37
+ Params.reject_reserved!(step[:params] || {}, JUDGMENT_RESERVED_PARAMS, judge_label)
38
+
39
+ step.merge(min_confidence: min_confidence(judge, judge_label))
40
+ end
41
+
42
+ # The judge's prompt: a non-blank String, or a Proc evaluated against the instance. Internal.
43
+ def normalize_instructions(value, label, name = :judge_instructions)
44
+ return value if value.is_a?(Proc)
45
+ return value.dup.freeze if value.is_a?(String) && !value.strip.empty?
46
+
47
+ raise ConfigurationError, "#{label}: #{name}: must be a non-blank String or a Proc"
48
+ end
49
+
50
+ private
51
+
52
+ def min_confidence(judge, label)
53
+ value = judge.fetch(:min_confidence, Judge::DEFAULT_MIN_CONFIDENCE)
54
+ return value.to_f if value.is_a?(Numeric) && value.between?(0, 1)
55
+
56
+ raise ConfigurationError, "#{label}: min_confidence: must be a number from 0 to 1, got #{value.inspect}"
57
+ end
58
+ end
59
+
60
+ option :compare, :proc
61
+ option :judge, ->(value, label, name) { normalize_judge(value, label, name) }
62
+ option :judge_instructions, ->(value, label, name) { normalize_instructions(value, label, name) }
63
+
64
+ def call(run, compare: nil, judge: nil, judge_instructions: nil)
65
+ stage = run.stages.first
66
+ # The judge is resolved before any sample is requested, so a missing one fails before costing anything.
67
+ judge = run.judge(judge:, instructions: judge_instructions)
68
+ first, second = run.samples([stage, stage])
69
+ return first if run.agree?(first, second, compare)
70
+
71
+ run.log("samples disagreed, asking the judge (#{judge.step.display_name})")
72
+ verdict = judge.verdict(first, second)
73
+ return { a: first, b: second }.fetch(verdict.choice) unless verdict.choice == :neither
74
+
75
+ models = [stage.first.model] * 2
76
+ raise DisagreementError.new(candidates: [first, second], verdict: :neither, reason: verdict.reason,
77
+ detail: verdict.detail, models: models + [judge.step.model])
78
+ end
79
+ end
80
+
81
+ Registry.register_built_in(:judged_squishsum, JudgedSquishsum)
82
+ end
83
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Harness
5
+ # The types a harness option can declare (see Base.option). Each checks a given value and returns it
6
+ # normalized, or raises ConfigurationError, so a bad option fails before any request is made.
7
+ # :step a model name or a step Hash (model:, provider:, params:, attempts:, forward_rejected:)
8
+ # :proc, :string, :symbol, :integer, :boolean
9
+ # A harness with a shape of its own declares the type as a callable instead, which gets the value, the
10
+ # declaration site, and the option's name, and returns the normalized value (or raises ConfigurationError).
11
+ module OptionTypes
12
+ TYPES = %i[step proc string symbol integer boolean].freeze
13
+
14
+ module_function
15
+
16
+ # Whether type is one a harness can declare: a built-in one above, or a callable that takes the value, the
17
+ # label, and the name.
18
+ def type?(type)
19
+ TYPES.include?(type) || (type.respond_to?(:call) && takes_three?(type))
20
+ end
21
+
22
+ # Whether the callable can be called with three positional arguments. One without a parameter list
23
+ # (an object with its own call) is trusted.
24
+ def takes_three?(callable)
25
+ return true if callable.is_a?(Proc) && !callable.lambda? # a proc drops the arguments it doesn't name
26
+
27
+ method = callable.is_a?(Proc) || callable.is_a?(Method) ? callable : callable.method(:call)
28
+ parameters = method.parameters.map(&:first)
29
+ return false if parameters.include?(:keyreq)
30
+ return true if parameters.include?(:rest)
31
+
32
+ 3.between?(parameters.count(:req), parameters.count(:req) + parameters.count(:opt))
33
+ end
34
+
35
+ # The value as the type's normalized form. label is the declaration site, name the option.
36
+ def check(type, value, label, name)
37
+ return deep_freeze(type.call(value, label, name)) if type.respond_to?(:call)
38
+
39
+ send(:"check_#{type}", value, "#{label} #{name}", "#{label}: #{name}:")
40
+ end
41
+
42
+ def check_step(value, label, _where)
43
+ step = ModelPath.normalize_step(value, label)
44
+ ModelPath.single_step!(step, "#{label}: a harness step")
45
+ deep_freeze(step)
46
+ end
47
+
48
+ def check_proc(value, _label, where)
49
+ check_kind(value, Proc, "a Proc", where)
50
+ end
51
+
52
+ def check_string(value, _label, where)
53
+ return value.dup.freeze if value.is_a?(String)
54
+
55
+ raise ConfigurationError, "#{where} must be a String, got #{value.class}"
56
+ end
57
+
58
+ def check_symbol(value, _label, where)
59
+ return value.to_sym if value.is_a?(Symbol) || value.is_a?(String)
60
+
61
+ raise ConfigurationError, "#{where} must be a Symbol, got #{value.class}"
62
+ end
63
+
64
+ def check_integer(value, _label, where)
65
+ check_kind(value, Integer, "an Integer", where)
66
+ end
67
+
68
+ def check_boolean(value, _label, where)
69
+ return value if [true, false].include?(value)
70
+
71
+ raise ConfigurationError, "#{where} must be true or false, got #{value.inspect}"
72
+ end
73
+
74
+ # The value itself when it is a kind, else a ConfigurationError saying what was expected.
75
+ def check_kind(value, kind, description, where)
76
+ return value if value.is_a?(kind)
77
+
78
+ raise ConfigurationError, "#{where} must be #{description}, got #{value.class}"
79
+ end
80
+
81
+ # A frozen copy: normalized steps are shared by every call that uses the declaration, so none can change
82
+ # one, and the caller's own objects stay as they were.
83
+ def deep_freeze(value)
84
+ case value
85
+ when Hash then value.to_h { |key, item| [key, deep_freeze(item)] }.freeze
86
+ when Array then value.map { |item| deep_freeze(item) }.freeze
87
+ when String then value.dup.freeze
88
+ else value
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Harness
5
+ # The harness types by name: the built-ins, registered as they load, and your own (Squishling.register_harness).
6
+ # Shared across threads, so every read and write is under the lock.
7
+ module Registry
8
+ NAME = /\A[a-z][a-z0-9_]*\z/
9
+
10
+ LOCK = Mutex.new
11
+ private_constant :LOCK
12
+ @built_in = {}
13
+ @custom = {}
14
+
15
+ class << self
16
+ def register_built_in(name, klass)
17
+ LOCK.synchronize { @built_in[name] = klass }
18
+ end
19
+
20
+ # Registers a harness class (or a block, which becomes the body of a Base subclass) under a name.
21
+ def register(name, klass = nil, &block)
22
+ raise ConfigurationError, "register_harness takes a class or a block, not both" if klass && block
23
+ raise ConfigurationError, "register_harness needs a Harness::Base subclass or a block" unless klass || block
24
+
25
+ name = checked_name(name)
26
+ klass = Class.new(Base, &block) if block
27
+ Base.check_registrable!(klass, name)
28
+ LOCK.synchronize do
29
+ raise ConfigurationError, "harness #{name.inspect} is already registered" if taken?(name)
30
+
31
+ @custom[name] = klass
32
+ end
33
+ klass
34
+ end
35
+
36
+ def fetch(name)
37
+ LOCK.synchronize { @built_in[name] || @custom[name] }
38
+ end
39
+
40
+ def names
41
+ LOCK.synchronize { @built_in.keys + @custom.keys }
42
+ end
43
+
44
+ # Forgets the registered harnesses; the built-ins stay.
45
+ def reset!
46
+ LOCK.synchronize { @custom.clear }
47
+ end
48
+
49
+ private
50
+
51
+ def checked_name(name)
52
+ unless name.is_a?(Symbol) || name.is_a?(String)
53
+ raise ConfigurationError, "a harness name must be a Symbol or String, got #{name.inspect}"
54
+ end
55
+
56
+ name = name.to_sym
57
+ return name if NAME.match?(name)
58
+
59
+ raise ConfigurationError,
60
+ "harness name #{name.inspect} must be lowercase letters, digits, and underscores, starting with a letter"
61
+ end
62
+
63
+ def taken?(name)
64
+ @built_in.key?(name) || @custom.key?(name)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Squishling
4
+ class Harness
5
+ # Two samples on the escalation's first step, asked concurrently. Identical outputs (or ones compare: calls
6
+ # equal) are accepted; different ones raise DisagreementError.
7
+ class Squishsum < Base
8
+ option :compare, :proc
9
+
10
+ def call(run, compare: nil)
11
+ stage = run.stages.first
12
+ first, second = run.samples([stage, stage])
13
+ return first if run.agree?(first, second, compare)
14
+
15
+ run.log("samples disagreed")
16
+ raise DisagreementError.new(candidates: [first, second], models: [stage.first.model] * 2)
17
+ end
18
+ end
19
+
20
+ Registry.register_built_in(:squishsum, Squishsum)
21
+ end
22
+ end
@@ -5,24 +5,10 @@ module Squishling
5
5
  # harness: :squishsum
6
6
  # harness: { type: :judged_squishsum, judge: "claude-opus-5-5", compare: ->(a, b, **) { a.team == b.team } }
7
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.
8
+ # escalation step twice, concurrently, and accepts the output only when both samples agree. :judged_squishsum
9
+ # hands disagreeing samples to a judge that picks one or rejects both. Your own types are registered with
10
+ # Squishling.register_harness (see Harness::Base).
11
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
12
  DEFAULT_JUDGE_INSTRUCTIONS = <<~TEXT.strip
27
13
  You are an impartial judge. Two independent attempts at the same operation returned different outputs,
28
14
  candidate "a" and candidate "b". Both already match the required output format, so judge only their
@@ -32,122 +18,62 @@ module Squishling
32
18
  whether either is correct. Never combine them or invent a third answer.
33
19
  TEXT
34
20
 
35
- attr_reader :type, :judge, :judge_instructions, :compare
21
+ # The type's validated options (frozen), and the Base subclass that runs it.
22
+ attr_reader :type, :klass, :options
36
23
 
37
24
  class << self
38
25
  def normalize(value, label)
39
26
  case value
40
- when Symbol, String then new(type: type_for(value, label))
27
+ when Symbol, String then build(type_for(value, label), {}, label)
41
28
  when Hash then from_hash(value, label)
42
29
  else
43
- raise ConfigurationError, "#{label}: harness must be one of #{TYPES.join(', ')} or a Hash with type:, " \
30
+ raise ConfigurationError, "#{label}: harness must be one of #{types.join(', ')} or a Hash with type:, " \
44
31
  "got #{value.inspect}"
45
32
  end
46
33
  end
47
34
 
35
+ # Every registered type: the built-ins, then your own in registration order.
36
+ def types
37
+ Registry.names
38
+ end
39
+
48
40
  private
49
41
 
50
42
  def from_hash(hash, label)
51
43
  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
44
  raise ConfigurationError, "#{label}: a harness Hash needs type:" unless hash.key?(:type)
55
45
 
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])
46
+ build(type_for(hash[:type], label), hash.except(:type), label)
60
47
  end
61
48
 
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(', ')})"
49
+ def build(type, options, label)
50
+ klass = Registry.fetch(type)
51
+ new(type:, klass:, options: klass.normalize_options(options, label))
67
52
  end
68
53
 
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)
54
+ def type_for(value, label)
55
+ type = value.to_s.to_sym if value.is_a?(Symbol) || value.is_a?(String)
56
+ return type if Registry.fetch(type)
124
57
 
125
- raise ConfigurationError, "#{label}: min_confidence: must be a number from 0 to 1, got #{value.inspect}"
58
+ raise ConfigurationError, "#{label}: unknown harness #{value.inspect} (use #{types.join(', ')})"
126
59
  end
127
60
  end
128
61
 
129
- def initialize(type:, judge: nil, judge_instructions: nil, compare: nil)
62
+ def initialize(type:, klass: Registry.fetch(type), options: {})
130
63
  @type = type
131
- @judge = judge
132
- @judge_instructions = judge_instructions
133
- @compare = compare
64
+ @klass = klass
65
+ @options = options.freeze
134
66
  freeze
135
67
  end
136
68
 
137
69
  DEFAULT = new(type: :escalation)
138
70
 
139
- # Whether the call asks two samples (squishsum and ensemble harnesses) rather than walking the escalation.
140
71
  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)
72
+ %i[squishsum judged_squishsum].include?(type)
147
73
  end
148
74
 
149
75
  def judged?
150
- JUDGED_TYPES.include?(type)
76
+ type == :judged_squishsum
151
77
  end
152
78
  end
153
79
  end