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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +133 -1
- data/README.md +63 -15
- data/docs/adapters.md +189 -0
- data/docs/configuration.md +89 -24
- data/docs/coolhand.md +66 -0
- data/docs/failures.md +129 -20
- data/docs/harnesses.md +186 -48
- data/docs/measuring-tokens.md +36 -42
- data/docs/observers.md +80 -0
- data/docs/routing.md +7 -2
- data/docs/schemas.md +33 -6
- data/docs/testing.md +183 -0
- data/docs/verifying.md +84 -0
- data/lib/squishling/adapter/error_body.rb +47 -0
- data/lib/squishling/adapter.rb +275 -0
- data/lib/squishling/adapters/openai_compatible.rb +304 -0
- data/lib/squishling/adapters/openai_options.rb +72 -0
- data/lib/squishling/adapters/ruby_llm.rb +193 -0
- data/lib/squishling/budget.rb +71 -0
- data/lib/squishling/class_methods.rb +43 -7
- data/lib/squishling/class_registry.rb +63 -0
- data/lib/squishling/configuration.rb +72 -8
- data/lib/squishling/definition.rb +95 -17
- data/lib/squishling/errors.rb +97 -12
- data/lib/squishling/harness/base.rb +136 -0
- data/lib/squishling/harness/escalation.rb +22 -0
- data/lib/squishling/harness/judged_squishsum.rb +83 -0
- data/lib/squishling/harness/option_types.rb +94 -0
- data/lib/squishling/harness/registry.rb +41 -0
- data/lib/squishling/harness/squishsum.rb +22 -0
- data/lib/squishling/harness.rb +25 -99
- data/lib/squishling/invoker.rb +28 -233
- data/lib/squishling/judge.rb +78 -21
- data/lib/squishling/llm_client.rb +73 -109
- data/lib/squishling/model_path.rb +117 -27
- data/lib/squishling/named_registry.rb +64 -0
- data/lib/squishling/observer.rb +109 -0
- data/lib/squishling/observers/coolhand.rb +86 -0
- data/lib/squishling/output_check.rb +13 -9
- data/lib/squishling/params.rb +15 -27
- data/lib/squishling/railtie.rb +10 -0
- data/lib/squishling/result.rb +37 -16
- data/lib/squishling/rotation.rb +31 -0
- data/lib/squishling/rspec.rb +18 -0
- data/lib/squishling/run/messages.rb +79 -0
- data/lib/squishling/run/rejections.rb +43 -0
- data/lib/squishling/run.rb +320 -0
- data/lib/squishling/schema.rb +10 -9
- data/lib/squishling/squawk.rb +33 -3
- data/lib/squishling/tasks/squishling.rake +18 -0
- data/lib/squishling/testing/adapter.rb +194 -0
- data/lib/squishling/testing/checks.rb +39 -0
- data/lib/squishling/testing/direct.rb +41 -0
- data/lib/squishling/testing/errors.rb +18 -0
- data/lib/squishling/testing/fixtures.rb +206 -0
- data/lib/squishling/testing/helpers.rb +71 -0
- data/lib/squishling/testing/minitest.rb +32 -0
- data/lib/squishling/testing/observer.rb +31 -0
- data/lib/squishling/testing/reply.rb +60 -0
- data/lib/squishling/testing/request.rb +75 -0
- data/lib/squishling/testing/rspec.rb +30 -0
- data/lib/squishling/testing/script.rb +189 -0
- data/lib/squishling/testing/state.rb +73 -0
- data/lib/squishling/testing/stubs.rb +112 -0
- data/lib/squishling/testing.rb +126 -0
- data/lib/squishling/verification.rb +39 -0
- data/lib/squishling/verifier.rb +179 -0
- data/lib/squishling/version.rb +1 -1
- data/lib/squishling.rb +89 -4
- metadata +53 -21
|
@@ -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 the adapter's judgments) and,
|
|
8
|
+
# for :judgment, min_confidence: (the probability the chosen candidate needs).
|
|
9
|
+
class JudgedSquishsum < Base
|
|
10
|
+
# Request keys the RubyLLM adapter'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,94 @@
|
|
|
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
|
+
# retry_temperature:)
|
|
9
|
+
# :proc, :string, :symbol, :integer, :boolean
|
|
10
|
+
# A harness with a shape of its own declares the type as a callable instead, which gets the value, the
|
|
11
|
+
# declaration site, and the option's name, and returns the normalized value (or raises ConfigurationError).
|
|
12
|
+
module OptionTypes
|
|
13
|
+
TYPES = %i[step proc string symbol integer boolean].freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Whether type is one a harness can declare: a built-in one above, or a callable that takes the value, the
|
|
18
|
+
# label, and the name.
|
|
19
|
+
def type?(type)
|
|
20
|
+
TYPES.include?(type) || (type.respond_to?(:call) && takes_three?(type))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Whether the callable can be called with three positional arguments. One without a parameter list
|
|
24
|
+
# (an object with its own call) is trusted.
|
|
25
|
+
def takes_three?(callable)
|
|
26
|
+
return true if callable.is_a?(Proc) && !callable.lambda? # a proc drops the arguments it doesn't name
|
|
27
|
+
|
|
28
|
+
method = callable.is_a?(Proc) || callable.is_a?(Method) ? callable : callable.method(:call)
|
|
29
|
+
parameters = method.parameters.map(&:first)
|
|
30
|
+
return false if parameters.include?(:keyreq)
|
|
31
|
+
return true if parameters.include?(:rest)
|
|
32
|
+
|
|
33
|
+
3.between?(parameters.count(:req), parameters.count(:req) + parameters.count(:opt))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The value as the type's normalized form. label is the declaration site, name the option.
|
|
37
|
+
def check(type, value, label, name)
|
|
38
|
+
return deep_freeze(type.call(value, label, name)) if type.respond_to?(:call)
|
|
39
|
+
|
|
40
|
+
send(:"check_#{type}", value, "#{label} #{name}", "#{label}: #{name}:")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_step(value, label, _where)
|
|
44
|
+
step = ModelPath.normalize_step(value, label)
|
|
45
|
+
ModelPath.single_step!(step, "#{label}: a harness step")
|
|
46
|
+
deep_freeze(step)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_proc(value, _label, where)
|
|
50
|
+
check_kind(value, Proc, "a Proc", where)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def check_string(value, _label, where)
|
|
54
|
+
return value.dup.freeze if value.is_a?(String)
|
|
55
|
+
|
|
56
|
+
raise ConfigurationError, "#{where} must be a String, got #{value.class}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def check_symbol(value, _label, where)
|
|
60
|
+
return value.to_sym if value.is_a?(Symbol) || value.is_a?(String)
|
|
61
|
+
|
|
62
|
+
raise ConfigurationError, "#{where} must be a Symbol, got #{value.class}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_integer(value, _label, where)
|
|
66
|
+
check_kind(value, Integer, "an Integer", where)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_boolean(value, _label, where)
|
|
70
|
+
return value if [true, false].include?(value)
|
|
71
|
+
|
|
72
|
+
raise ConfigurationError, "#{where} must be true or false, got #{value.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# The value itself when it is a kind, else a ConfigurationError saying what was expected.
|
|
76
|
+
def check_kind(value, kind, description, where)
|
|
77
|
+
return value if value.is_a?(kind)
|
|
78
|
+
|
|
79
|
+
raise ConfigurationError, "#{where} must be #{description}, got #{value.class}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# A frozen copy: normalized steps are shared by every call that uses the declaration, so none can change
|
|
83
|
+
# one, and the caller's own objects stay as they were.
|
|
84
|
+
def deep_freeze(value)
|
|
85
|
+
case value
|
|
86
|
+
when Hash then value.to_h { |key, item| [key, deep_freeze(item)] }.freeze
|
|
87
|
+
when Array then value.map { |item| deep_freeze(item) }.freeze
|
|
88
|
+
when String then value.dup.freeze
|
|
89
|
+
else value
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
module Registry
|
|
7
|
+
STORE = NamedRegistry.new("harness")
|
|
8
|
+
private_constant :STORE
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def register_built_in(name, klass)
|
|
12
|
+
STORE.register_built_in(name, klass)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Registers a harness class (or a block, which becomes the body of a Base subclass) under a name.
|
|
16
|
+
def register(name, klass = nil, &block)
|
|
17
|
+
raise ConfigurationError, "register_harness takes a class or a block, not both" if klass && block
|
|
18
|
+
raise ConfigurationError, "register_harness needs a Harness::Base subclass or a block" unless klass || block
|
|
19
|
+
|
|
20
|
+
name = STORE.checked_name(name)
|
|
21
|
+
klass = Class.new(Base, &block) if block
|
|
22
|
+
Base.check_registrable!(klass, name)
|
|
23
|
+
STORE.add(name, klass)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def fetch(name)
|
|
27
|
+
STORE.fetch(name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def names
|
|
31
|
+
STORE.names
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Forgets the registered harnesses; the built-ins stay.
|
|
35
|
+
def reset!
|
|
36
|
+
STORE.reset!
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
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
|
data/lib/squishling/harness.rb
CHANGED
|
@@ -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. :
|
|
9
|
-
#
|
|
10
|
-
#
|
|
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
|
-
|
|
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
|
|
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 #{
|
|
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
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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}:
|
|
58
|
+
raise ConfigurationError, "#{label}: unknown harness #{value.inspect} (use #{types.join(', ')})"
|
|
126
59
|
end
|
|
127
60
|
end
|
|
128
61
|
|
|
129
|
-
def initialize(type:,
|
|
62
|
+
def initialize(type:, klass: Registry.fetch(type), options: {})
|
|
130
63
|
@type = type
|
|
131
|
-
@
|
|
132
|
-
@
|
|
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
|
|
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
|
-
|
|
76
|
+
type == :judged_squishsum
|
|
151
77
|
end
|
|
152
78
|
end
|
|
153
79
|
end
|