squishling 0.2.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +140 -0
  3. data/README.md +118 -31
  4. data/docs/configuration.md +75 -21
  5. data/docs/coolhand.md +66 -0
  6. data/docs/failures.md +73 -9
  7. data/docs/harnesses.md +326 -0
  8. data/docs/measuring-tokens.md +140 -0
  9. data/docs/naming.md +43 -0
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +36 -27
  12. data/docs/schemas.md +24 -5
  13. data/lib/squishling/appendices.rb +3 -3
  14. data/lib/squishling/class_methods.rb +60 -29
  15. data/lib/squishling/collisions.rb +54 -0
  16. data/lib/squishling/configuration.rb +32 -0
  17. data/lib/squishling/definition.rb +56 -27
  18. data/lib/squishling/errors.rb +22 -1
  19. data/lib/squishling/harness/base.rb +136 -0
  20. data/lib/squishling/harness/escalation.rb +22 -0
  21. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  22. data/lib/squishling/harness/option_types.rb +93 -0
  23. data/lib/squishling/harness/registry.rb +69 -0
  24. data/lib/squishling/harness/squishsum.rb +22 -0
  25. data/lib/squishling/harness.rb +79 -0
  26. data/lib/squishling/invoker.rb +26 -188
  27. data/lib/squishling/judge.rb +186 -0
  28. data/lib/squishling/llm_client.rb +180 -0
  29. data/lib/squishling/model_path.rb +82 -25
  30. data/lib/squishling/observer.rb +100 -0
  31. data/lib/squishling/observers/coolhand.rb +86 -0
  32. data/lib/squishling/output_check.rb +109 -0
  33. data/lib/squishling/params.rb +47 -6
  34. data/lib/squishling/rotation.rb +26 -0
  35. data/lib/squishling/router.rb +8 -2
  36. data/lib/squishling/run/messages.rb +42 -0
  37. data/lib/squishling/run.rb +296 -0
  38. data/lib/squishling/source.rb +5 -5
  39. data/lib/squishling/squawk.rb +52 -0
  40. data/lib/squishling/version.rb +1 -1
  41. data/lib/squishling.rb +56 -7
  42. metadata +25 -3
@@ -7,45 +7,52 @@ module Squishling
7
7
  # Matches the keys a squish! context: Hash may use.
8
8
  NAME_KEY = ->(key) { key.is_a?(String) || key.is_a?(Symbol) }
9
9
 
10
- attr_reader :klass, :name, :call_context
10
+ attr_reader :klass, :name, :call_context, :handed_off
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: {}, handed_off: false)
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
29
+ @handed_off = handed_off
26
30
  end
27
31
 
28
32
  # This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
29
33
  # 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)
34
+ def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
35
+ provider: nil, params: nil, harness: nil)
32
36
  call_path = ModelPath.declare(model, escalation, "#{label} squish!")
33
37
  raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
34
38
  unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
35
39
  raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
36
40
  end
37
41
 
38
- appended = Appendices.normalize(append_instructions, "#{label} squish!") unless append_instructions.nil?
42
+ appended = Appendices.normalize(append_to_purpose, "#{label} squish!") unless append_to_purpose.nil?
39
43
  call_params = params && Params.normalize(params, "#{label} squish! params")
44
+ call_harness = Harness.normalize(harness, "#{label} squish!") unless harness.nil?
40
45
  self.class.new(
41
46
  klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
42
- validator: @validator,
43
- instructions: instructions || @instructions,
44
- append_instructions: [*@append_instructions, *appended],
47
+ validator: @validator, squawk: @squawk,
48
+ purpose: purpose || @purpose,
49
+ append_to_purpose: [*@append_to_purpose, *appended],
45
50
  model: call_path || @model, provider: call_path ? provider : @provider,
46
51
  # merge, not Params.resolve: a nil at the method level must still unset the class's key.
47
52
  params: call_params ? (@params || {}).merge(call_params) : @params,
48
- call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
53
+ harness: call_harness || @harness,
54
+ call_context: @call_context.merge((context || {}).transform_keys(&:to_sym)),
55
+ handed_off: true # only squish! builds a definition this way; it is not part of a declared one
49
56
  )
50
57
  end
51
58
 
@@ -53,17 +60,17 @@ module Squishling
53
60
  "#{klass}##{name}"
54
61
  end
55
62
 
56
- # The system prompt: the instructions, then each append_instructions section.
57
- def instructions(receiver)
58
- value = @instructions || klass.instructions
63
+ # The system prompt: the purpose, then each append_to_purpose section.
64
+ def purpose(receiver)
65
+ value = @purpose || klass.purpose
59
66
  value = receiver.instance_exec(&value) if value.is_a?(Proc)
60
67
  return value if value.nil? || value.empty?
61
68
 
62
- [value, *Appendices.render(append_instructions, receiver, label)].join("\n\n")
69
+ [value, *Appendices.render(append_to_purpose, receiver, label)].join("\n\n")
63
70
  end
64
71
 
65
- def append_instructions
66
- Appendices.resolve(klass.squishling_append_instructions + (@append_instructions || []))
72
+ def append_to_purpose
73
+ Appendices.resolve(klass.squishling_append_to_purpose + (@append_to_purpose || []))
67
74
  end
68
75
 
69
76
  def schema
@@ -73,13 +80,20 @@ module Squishling
73
80
 
74
81
  # One ModelPath::Step per attempt, from the first level (method, class, or config) that declares a model
75
82
  # or escalation, or a single attempt on RubyLLM's default model. A provider travels with the model declared
76
- # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider.
83
+ # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider. Models
84
+ # tied on priority are rotated by this method's call count (see Rotation).
77
85
  def escalation_path
78
86
  config = Squishling.config
79
87
  entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
80
88
  [config.default_model_path, config.default_provider]].find(&:first)
81
- entries ||= [{ model: nil, attempts: 1 }]
82
- ModelPath.steps(entries, provider:, params:)
89
+ entries ||= [{ model: nil, attempts: 1, forward_rejected: true }]
90
+ turn = ModelPath.rotating?(entries) ? Rotation.advance(klass, name) : 0
91
+ ModelPath.steps(entries, provider:, params:, turn:)
92
+ end
93
+
94
+ # How the escalation is used (see Harness): the method's, the class's, the configured default, or :escalation.
95
+ def harness
96
+ @harness || klass.squishling_harness || Squishling.config.default_harness || Harness::DEFAULT
83
97
  end
84
98
 
85
99
  # Generation params: config defaults, overridden key by key by the class, then by the method.
@@ -92,6 +106,12 @@ module Squishling
92
106
  @validator || klass.squishling_validator
93
107
  end
94
108
 
109
+ # The observability hook: the method's, else the class's, else the configured one. `false` at a level
110
+ # silences the levels above it.
111
+ def squawk
112
+ [@squawk, klass.squishling_squawk, Squishling.config.squawk].find { |hook| !hook.nil? } || nil
113
+ end
114
+
95
115
  def context_names
96
116
  klass.squishling_context_names
97
117
  end
@@ -115,18 +135,20 @@ module Squishling
115
135
  coerce(receiver.instance_exec(e, **inputs, &handler))
116
136
  end
117
137
 
118
- # Deterministic return values: hashes are validated and turned into the typed result;
119
- # anything else (including an already-built result) passes through.
138
+ # Deterministic and fallback return values: with an output_schema, every value is validated and
139
+ # typed like an LLM result. Without one, the value passes through untouched.
120
140
  def coerce(value)
121
- value.is_a?(Hash) && schema ? build_result(value, squished: false) : value
141
+ schema ? build_result(value, squished: false) : value
122
142
  end
123
143
 
144
+ # A result of this schema's own class passes through; any other result is re-validated via to_h.
124
145
  def build_result(attrs, squished:)
125
146
  raise ConfigurationError, "#{label} has no output_schema" unless schema
126
- return attrs if attrs.is_a?(Result::Instance)
147
+ return attrs if schema.result_class && attrs.is_a?(schema.result_class)
127
148
 
128
- data = Schema.jsonify(attrs)
129
- errors = schema.validate(data)
149
+ attrs = attrs.to_h if attrs.is_a?(Result::Instance)
150
+ data, errors = jsonify_return(attrs)
151
+ errors = schema.validate(data) if errors.empty?
130
152
  raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
131
153
 
132
154
  schema.build(data, squished:)
@@ -162,6 +184,13 @@ module Squishling
162
184
 
163
185
  private
164
186
 
187
+ # Values JSON can't represent (NaN, Infinity, cycles) are invalid output, not a raw JSON error.
188
+ def jsonify_return(attrs)
189
+ [Schema.jsonify(attrs), []]
190
+ rescue JSON::GeneratorError, JSON::NestingError => e
191
+ [nil, [e.message]]
192
+ end
193
+
165
194
  # The implementation's parameters, beneath the prepended wrappers.
166
195
  def parameters
167
196
  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 harness's samples (the squishsum harnesses', or a custom one's) were valid but didn't agree, and either no
27
+ # judge was declared (verdict nil) or the judge rejected both (verdict :neither, with its reason). candidates
28
+ # holds the typed results, so a squish_fallback can still return one of them, and raw all of them as Hashes.
29
+ # models names the model behind each role (sample a, sample b, then the judge if one ran), not one entry per
30
+ # attempt; attempts 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,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