squishling 0.1.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.
@@ -9,38 +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, 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
26
+ @validator = validator
27
+ @squawk = squawk
24
28
  @call_context = call_context
25
29
  end
26
30
 
27
- # This definition with one call's overrides on top. The output schema and predicate can't be overridden:
28
- # the call must still return the method's result type.
29
- def for_call(instructions: nil, append_instructions: nil, context: nil, model: nil, provider: nil, params: nil)
30
- raise ConfigurationError, "#{label}: squish! provider: needs a model:" if provider && !model
31
+ # This definition with one call's overrides on top. The output schema, predicate, fallback, and validator
32
+ # can't be overridden: the call must still return the method's result type.
33
+ def for_call(purpose: nil, append_to_purpose: nil, context: nil, model: nil, escalation: nil,
34
+ provider: nil, params: nil, harness: nil)
35
+ call_path = ModelPath.declare(model, escalation, "#{label} squish!")
36
+ raise ConfigurationError, "#{label}: squish! provider: needs a model: or escalation:" if provider && !call_path
31
37
  unless context.nil? || (context.is_a?(Hash) && context.each_key.all?(NAME_KEY))
32
38
  raise ConfigurationError, "#{label}: squish! context: must be a Hash with String or Symbol keys"
33
39
  end
34
40
 
35
- 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?
36
42
  call_params = params && Params.normalize(params, "#{label} squish! params")
43
+ call_harness = Harness.normalize(harness, "#{label} squish!") unless harness.nil?
37
44
  self.class.new(
38
45
  klass:, name:, output_schema: @output_schema, predicate: @predicate, fallback: @fallback,
39
- instructions: instructions || @instructions,
40
- append_instructions: [*@append_instructions, *appended],
41
- model: model || @model, provider: model ? provider : @provider,
46
+ validator: @validator, squawk: @squawk,
47
+ purpose: purpose || @purpose,
48
+ append_to_purpose: [*@append_to_purpose, *appended],
49
+ model: call_path || @model, provider: call_path ? provider : @provider,
42
50
  # merge, not Params.resolve: a nil at the method level must still unset the class's key.
43
51
  params: call_params ? (@params || {}).merge(call_params) : @params,
52
+ harness: call_harness || @harness,
44
53
  call_context: @call_context.merge((context || {}).transform_keys(&:to_sym))
45
54
  )
46
55
  end
@@ -49,17 +58,17 @@ module Squishling
49
58
  "#{klass}##{name}"
50
59
  end
51
60
 
52
- # The system prompt: the instructions, then each append_instructions section.
53
- def instructions(receiver)
54
- 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
55
64
  value = receiver.instance_exec(&value) if value.is_a?(Proc)
56
65
  return value if value.nil? || value.empty?
57
66
 
58
- [value, *Appendices.render(append_instructions, receiver, label)].join("\n\n")
67
+ [value, *Appendices.render(append_to_purpose, receiver, label)].join("\n\n")
59
68
  end
60
69
 
61
- def append_instructions
62
- Appendices.resolve(klass.squishling_append_instructions + (@append_instructions || []))
70
+ def append_to_purpose
71
+ Appendices.resolve(klass.squishling_append_to_purpose + (@append_to_purpose || []))
63
72
  end
64
73
 
65
74
  def schema
@@ -67,20 +76,20 @@ module Squishling
67
76
  raw && Schema.for(raw)
68
77
  end
69
78
 
70
- def model
71
- model_and_provider.first
79
+ # One ModelPath::Step per attempt, from the first level (method, class, or config) that declares a model
80
+ # or escalation, or a single attempt on RubyLLM's default model. A provider travels with the model declared
81
+ # at the same level, so a per-method Anthropic model never inherits a class-level OpenAI provider.
82
+ def escalation_path
83
+ config = Squishling.config
84
+ entries, provider = [[@model, @provider], [klass.squishling_model_path, klass.squishling_provider],
85
+ [config.default_model_path, config.default_provider]].find(&:first)
86
+ entries ||= [{ model: nil, attempts: 1, forward_rejected: true }]
87
+ ModelPath.steps(entries, provider:, params:)
72
88
  end
73
89
 
74
- def provider
75
- model_and_provider.last
76
- end
77
-
78
- # A provider travels with the model declared at the same level (method, class, or config),
79
- # so a per-method Anthropic model never inherits a class-level OpenAI provider.
80
- def model_and_provider
81
- config = Squishling.config
82
- [[@model, @provider], [klass.squishling_model, klass.squishling_provider],
83
- [config.default_model, config.default_provider]].find(&:first) || [nil, nil]
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
84
93
  end
85
94
 
86
95
  # Generation params: config defaults, overridden key by key by the class, then by the method.
@@ -88,6 +97,17 @@ module Squishling
88
97
  Params.resolve(Squishling.config.default_params, klass.squishling_params, @params)
89
98
  end
90
99
 
100
+ # Extra output checks run on schema-valid LLM results (see ClassMethods#squish_validate).
101
+ def validator
102
+ @validator || klass.squishling_validator
103
+ end
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
+
91
111
  def context_names
92
112
  klass.squishling_context_names
93
113
  end
@@ -111,43 +131,62 @@ module Squishling
111
131
  coerce(receiver.instance_exec(e, **inputs, &handler))
112
132
  end
113
133
 
114
- # Deterministic return values: hashes are validated and turned into the typed result;
115
- # 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.
116
136
  def coerce(value)
117
- value.is_a?(Hash) && schema ? build_result(value, squished: false) : value
137
+ schema ? build_result(value, squished: false) : value
118
138
  end
119
139
 
140
+ # A result of this schema's own class passes through; any other result is re-validated via to_h.
120
141
  def build_result(attrs, squished:)
121
142
  raise ConfigurationError, "#{label} has no output_schema" unless schema
122
- return attrs if attrs.is_a?(Result::Instance)
143
+ return attrs if schema.result_class && attrs.is_a?(schema.result_class)
123
144
 
124
- data = Schema.jsonify(attrs)
125
- 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?
126
148
  raise InvalidOutputError.new(errors:, raw: attrs, source: "Deterministic") if errors.any?
127
149
 
128
150
  schema.build(data, squished:)
129
151
  end
130
152
 
131
- # Map positional and keyword arguments onto the original method's parameter names.
153
+ # Map positional and keyword arguments onto the original method's parameter names. A positional with no
154
+ # name (a destructuring parameter, or one beyond the declared parameters) is sent as arg0, arg1, ...
132
155
  def bind_arguments(args, kwargs)
133
156
  positional = args.dup
134
157
  bound = {}
158
+ unnamed = 0
135
159
 
136
160
  parameters.each do |type, param|
137
161
  case type
138
162
  when :req, :opt
139
- bound[param] = positional.shift unless positional.empty? || param.nil?
163
+ next if positional.empty?
164
+
165
+ value = positional.shift
166
+ if param
167
+ bound[param] = value
168
+ else
169
+ bound[:"arg#{unnamed}"] = value
170
+ unnamed += 1
171
+ end
140
172
  when :rest
141
173
  bound[param.nil? || param == :* ? :args : param] = positional.shift(positional.size)
142
174
  end
143
175
  end
144
- positional.each_with_index { |value, index| bound[:"arg#{index}"] = value }
176
+ positional.each_with_index { |value, index| bound[:"arg#{unnamed + index}"] = value }
145
177
 
146
178
  bound.merge(kwargs)
147
179
  end
148
180
 
149
181
  private
150
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
+
151
190
  # The implementation's parameters, beneath the prepended wrappers.
152
191
  def parameters
153
192
  Wrapper.implementation(klass.instance_method(name))&.parameters || []
@@ -3,20 +3,44 @@
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
 
10
- # Output that didn't match the schema: from the LLM after all retries, or from the deterministic path.
10
+ # Output that didn't match the schema (or a squish_validate check): from the LLM after every attempt in the
11
+ # escalation, or from the deterministic path.
11
12
  class InvalidOutputError < Error
12
- attr_reader :errors, :raw, :attempts
13
+ # models: the model ids tried, one per attempt (nil entries mean RubyLLM's default model).
14
+ attr_reader :errors, :raw, :attempts, :models
13
15
 
14
- def initialize(errors:, raw: nil, source: "LLM", attempts: nil)
16
+ def initialize(errors:, raw: nil, source: "LLM", attempts: nil, models: nil)
15
17
  @errors = errors
16
18
  @raw = raw
17
19
  @attempts = attempts
20
+ @models = models
18
21
  tries = attempts ? " after #{attempts} attempt#{'s' unless attempts == 1}" : ""
19
- super("#{source} output did not match the output schema#{tries}: #{errors.join('; ')}")
22
+ super("#{source} output was invalid#{tries}: #{errors.join('; ')}")
23
+ end
24
+ end
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:)
20
44
  end
21
45
  end
22
46
 
@@ -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