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.
Files changed (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +133 -1
  3. data/README.md +63 -15
  4. data/docs/adapters.md +189 -0
  5. data/docs/configuration.md +89 -24
  6. data/docs/coolhand.md +66 -0
  7. data/docs/failures.md +129 -20
  8. data/docs/harnesses.md +186 -48
  9. data/docs/measuring-tokens.md +36 -42
  10. data/docs/observers.md +80 -0
  11. data/docs/routing.md +7 -2
  12. data/docs/schemas.md +33 -6
  13. data/docs/testing.md +183 -0
  14. data/docs/verifying.md +84 -0
  15. data/lib/squishling/adapter/error_body.rb +47 -0
  16. data/lib/squishling/adapter.rb +275 -0
  17. data/lib/squishling/adapters/openai_compatible.rb +304 -0
  18. data/lib/squishling/adapters/openai_options.rb +72 -0
  19. data/lib/squishling/adapters/ruby_llm.rb +193 -0
  20. data/lib/squishling/budget.rb +71 -0
  21. data/lib/squishling/class_methods.rb +43 -7
  22. data/lib/squishling/class_registry.rb +63 -0
  23. data/lib/squishling/configuration.rb +72 -8
  24. data/lib/squishling/definition.rb +95 -17
  25. data/lib/squishling/errors.rb +97 -12
  26. data/lib/squishling/harness/base.rb +136 -0
  27. data/lib/squishling/harness/escalation.rb +22 -0
  28. data/lib/squishling/harness/judged_squishsum.rb +83 -0
  29. data/lib/squishling/harness/option_types.rb +94 -0
  30. data/lib/squishling/harness/registry.rb +41 -0
  31. data/lib/squishling/harness/squishsum.rb +22 -0
  32. data/lib/squishling/harness.rb +25 -99
  33. data/lib/squishling/invoker.rb +28 -233
  34. data/lib/squishling/judge.rb +78 -21
  35. data/lib/squishling/llm_client.rb +73 -109
  36. data/lib/squishling/model_path.rb +117 -27
  37. data/lib/squishling/named_registry.rb +64 -0
  38. data/lib/squishling/observer.rb +109 -0
  39. data/lib/squishling/observers/coolhand.rb +86 -0
  40. data/lib/squishling/output_check.rb +13 -9
  41. data/lib/squishling/params.rb +15 -27
  42. data/lib/squishling/railtie.rb +10 -0
  43. data/lib/squishling/result.rb +37 -16
  44. data/lib/squishling/rotation.rb +31 -0
  45. data/lib/squishling/rspec.rb +18 -0
  46. data/lib/squishling/run/messages.rb +79 -0
  47. data/lib/squishling/run/rejections.rb +43 -0
  48. data/lib/squishling/run.rb +320 -0
  49. data/lib/squishling/schema.rb +10 -9
  50. data/lib/squishling/squawk.rb +33 -3
  51. data/lib/squishling/tasks/squishling.rake +18 -0
  52. data/lib/squishling/testing/adapter.rb +194 -0
  53. data/lib/squishling/testing/checks.rb +39 -0
  54. data/lib/squishling/testing/direct.rb +41 -0
  55. data/lib/squishling/testing/errors.rb +18 -0
  56. data/lib/squishling/testing/fixtures.rb +206 -0
  57. data/lib/squishling/testing/helpers.rb +71 -0
  58. data/lib/squishling/testing/minitest.rb +32 -0
  59. data/lib/squishling/testing/observer.rb +31 -0
  60. data/lib/squishling/testing/reply.rb +60 -0
  61. data/lib/squishling/testing/request.rb +75 -0
  62. data/lib/squishling/testing/rspec.rb +30 -0
  63. data/lib/squishling/testing/script.rb +189 -0
  64. data/lib/squishling/testing/state.rb +73 -0
  65. data/lib/squishling/testing/stubs.rb +112 -0
  66. data/lib/squishling/testing.rb +126 -0
  67. data/lib/squishling/verification.rb +39 -0
  68. data/lib/squishling/verifier.rb +179 -0
  69. data/lib/squishling/version.rb +1 -1
  70. data/lib/squishling.rb +89 -4
  71. metadata +53 -21
data/docs/harnesses.md CHANGED
@@ -1,4 +1,4 @@
1
- # Harnesses: escalation, squishsum, and ensemble
1
+ # Harnesses: escalation, squishsum, and your own
2
2
 
3
3
  A squished call's **harness** decides how it uses its [escalation](configuration.md#models-and-escalation):
4
4
 
@@ -7,13 +7,12 @@ A squished call's **harness** decides how it uses its [escalation](configuration
7
7
  | `:escalation` (default) | Tries each attempt in order until an output passes the schema and `squish_validate`. | 1 or more |
8
8
  | `:squishsum` | Asks the escalation's **first step** twice, concurrently. Identical outputs are accepted; different ones raise `Squishling::DisagreementError`. | 2 or more |
9
9
  | `:judged_squishsum` | Like `:squishsum`, but when the two outputs differ, a **judge** picks one or rejects both. | 2 or more, plus 1 or more judge requests when they differ |
10
- | `:ensemble` | Asks the escalation's **first and second steps** once each, concurrently, so two different models are compared. Identical outputs are accepted; different ones raise `DisagreementError`. | 2 or more |
11
- | `:judged_ensemble` | Like `:ensemble`, but when the two outputs differ, a **judge** picks one or rejects both. | 2 or more, plus 1 or more judge requests when they differ |
10
+
11
+ Need a different trade-off, such as two models plus a tiebreaker or a majority vote? Register your own with
12
+ [`Squishling.register_harness`](#custom-harnesses).
12
13
 
13
14
  Use a squishsum harness when a confidently wrong answer costs more than a second request: one sample can make a
14
- mistake, but two independent samples rarely make the same one. Use an [ensemble](#ensembles) when the two samples
15
- should come from different models: a model rarely disagrees with itself at low temperature, but a cheap and a
16
- stronger model often disagree on exactly the cases the cheap one gets confidently wrong.
15
+ mistake, but two independent samples rarely make the same one.
17
16
 
18
17
  ```ruby
19
18
  class TicketTriager
@@ -47,25 +46,25 @@ The first level that declares one wins: `squish!`, then the method, then the cla
47
46
 
48
47
  | Option | Harnesses | Description |
49
48
  |---|---|---|
50
- | `type:` | all | `:escalation`, `:squishsum`, `:judged_squishsum`, `:ensemble`, or `:judged_ensemble` (required in the Hash form). |
51
- | `compare:` | squishsum and ensemble ones | `->(a, b, **inputs) { ... }`, evaluated against the instance with the two typed results and the method's inputs. Truthy means the samples agree. Default: the two outputs are exactly equal. |
52
- | `judge:` | judged ones | The judge step (see [The judge](#the-judge)). Default: the escalation's next step after the sampled ones. |
49
+ | `type:` | all | `:escalation`, `:squishsum`, `:judged_squishsum`, or a [registered](#custom-harnesses) name (required in the Hash form). |
50
+ | `compare:` | squishsum ones | `->(a, b, **inputs) { ... }`, evaluated against the instance with the two typed results and the method's inputs. Truthy means the samples agree. Default: the two outputs are exactly equal. |
51
+ | `judge:` | judged ones | The judge step (see [The judge](#the-judge)). Default: the escalation's next step after the sampled one. |
53
52
  | `judge_instructions:` | judged ones | A String, or a Proc evaluated against the instance, that replaces the [default judge prompt](#the-default-judge-prompt). |
54
53
 
55
54
  ## Samples
56
55
 
57
- Under `:squishsum`, both samples run on the escalation's first step, with its model, provider, and params
58
- (an [ensemble](#ensembles) runs them on its first and second steps):
56
+ Both samples run on the escalation's first step, with its model, provider, and params:
59
57
 
60
58
  - **They're independent.** Each sample has its own chat, and the two requests are sent concurrently on separate
61
59
  threads. Only the provider requests run on those threads. Parsing, schema validation, `squish_validate`,
62
60
  `compare:`, and the judge all run on the calling thread, so your Squishling callbacks never run concurrently.
63
- RubyLLM instrumentation subscribers (e.g. `ActiveSupport::Notifications` listeners for `chat.ruby_llm`) do see
64
- each sample's request on its worker thread, outside the Rails executor, so keep them thread-safe.
61
+ With the RubyLLM adapter, instrumentation subscribers (e.g. `ActiveSupport::Notifications` listeners for
62
+ `chat.ruby_llm`) do see each sample's request on its worker thread, outside the Rails executor, so keep them thread-safe.
65
63
  - **Each one is retried like an escalation step.** A sample with invalid output is re-asked in its own chat, up to
66
- their step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
64
+ the first step's `attempts:`, while a sample that already passed keeps its result. A failed request gets a fresh
67
65
  chat. A sample still failing after its attempts fails the call with `InvalidOutputError` or `LLMError`, as
68
- usual. Under `:squishsum`, later escalation steps are never used for samples (see [Ensembles](#ensembles)).
66
+ usual. Later escalation steps are never used for samples (a judge may use the next one, see
67
+ [The judge](#the-judge)).
69
68
  - **Agreement is exact by default.** Free-text fields seldom match word for word, so pass `compare:` to decide
70
69
  which fields must match:
71
70
 
@@ -75,32 +74,6 @@ Under `:squishsum`, both samples run on the escalation's first step, with its mo
75
74
 
76
75
  When the samples agree, the first one is returned.
77
76
 
78
- ## Ensembles
79
-
80
- `:ensemble` and `:judged_ensemble` sample two *different* steps instead of one step twice. Sample `a` is the
81
- escalation's first step and sample `b` its second, each with its own model, provider, and params:
82
-
83
- ```ruby
84
- class TicketTriager
85
- include Squishling
86
-
87
- squishling escalation: [{ model: "claude-haiku-4-5", attempts: 2 }, "claude-sonnet-5-5", "claude-opus-5-5"],
88
- harness: :judged_ensemble # a = Haiku, b = Sonnet, Opus judges when they differ
89
- # purpose, output_schema, ...
90
- end
91
- ```
92
-
93
- - A step's `attempts:` are that sample's retries (here Haiku gets two attempts, Sonnet one), exactly as in
94
- [Samples](#samples). Steps after the second are never sampled.
95
- - Everything else matches squishsum: independence and concurrency, `compare:`, `DisagreementError`, and the
96
- judge. `DisagreementError#models` names the model behind each sample.
97
- - The judge defaults to the escalation's **third** step. With only two steps, a `:judged_ensemble` needs `judge:`.
98
- An ensemble with fewer than two steps, or a judged one with neither a third step nor `judge:`, raises
99
- `ConfigurationError` before any request.
100
- - A global `default_harness = :ensemble` therefore needs every class to declare at least two steps, and a
101
- `squish!(model: ...)` override (one step) can't be combined with an ensemble harness.
102
- - Steps are told apart by value, so two adjacent steps with the same model, provider, and params count as one step.
103
-
104
77
  ## The judge
105
78
 
106
79
  With a judged harness, two valid samples that disagree go to a judge. The judge picks candidate `a` or `b`,
@@ -110,9 +83,9 @@ whose typed result is returned (`squished?` is `true`), or it picks `neither`, w
110
83
  The judge is:
111
84
 
112
85
  1. the `judge:` step, if declared: a model name, or a Hash with `model:`, `provider:`, `params:`, and `attempts:`
113
- (as in an escalation step, without `order:`), plus `type:`; or
114
- 2. the escalation's next step after the sampled ones (the second under `:judged_squishsum`, the third under
115
- `:judged_ensemble`), with its attempts.
86
+ (as in an escalation step, without `priority:`, `rotate:`, or `include_when:`), plus `type:`; or
87
+ 2. the escalation's next step after the sampled one, with its attempts. Steps tied on `priority:` rotate between calls
88
+ (see [Rotating models](configuration.md#rotating-models-within-a-priority)), so which model that is can vary.
116
89
 
117
90
  A judged harness with neither raises `ConfigurationError` before any sample is requested. A `judge:`
118
91
  step uses only its own `provider:`; it doesn't inherit the class or method `provider:`, which belongs to their models
@@ -135,8 +108,10 @@ valid verdict raises `InvalidOutputError`. A chat judge's params are the method'
135
108
  ### System One judgment models (Jev)
136
109
 
137
110
  A judge can also be a System One decision model, such as TypeSafe's Jev, via
138
- [RubyLLM judgments](https://rubyllm.com/judgments/) (RubyLLM 2.1+). Decision models return calibrated
139
- probabilities rather than text, so they are much faster and cheaper than a chat judge.
111
+ [RubyLLM judgments](https://rubyllm.com/judgments/) (RubyLLM 2.1+). This needs an [adapter](adapters.md) that
112
+ supports judgments, which today means the `:ruby_llm` adapter; on any other, a `type: :judgment` judge raises
113
+ `ConfigurationError` before a request is made (a `type: :chat` judge works with every adapter). Decision models return
114
+ calibrated probabilities rather than text, so they are much faster and cheaper than a chat judge.
140
115
 
141
116
  ```ruby
142
117
  squishling harness: {
@@ -149,8 +124,8 @@ Squishling asks one `choice` question (`a`, `b`, or `neither`), with the judge p
149
124
  operation's purpose, input, output schema, and candidates as the judgment input. A pick of `a` or `b` is
150
125
  accepted only when its probability is at least `min_confidence:` (default `0.8`). Otherwise, as with `neither`,
151
126
  `DisagreementError` is raised with the choice and its probability in its `reason`. A judgment judge sends only its own
152
- `params:` (as RubyLLM `provider_options:`). `attempts:` retries a failed request. Configure the provider's
153
- credentials in RubyLLM as usual.
127
+ `params:` (as RubyLLM `provider_options:`). `attempts:` retries a failed request. Judgments are a RubyLLM feature, so
128
+ they need the `:ruby_llm` adapter, with the provider's credentials configured in RubyLLM as usual.
154
129
 
155
130
  ### The default judge prompt
156
131
 
@@ -193,6 +168,169 @@ end
193
168
  | `attempts` | `nil` |
194
169
  | `models` | The model behind each role: sample `a`, sample `b`, then the judge if one ran (one entry each, not one per attempt) |
195
170
 
171
+ A [token budget](failures.md#capping-a-calls-cost-token-budgets) covers every request of the call, the samples
172
+ and the judge's included. When it is spent before the next round of retries or before the judge can run, the call raises
173
+ `BudgetExhaustedError` (also an `InvalidOutputError`, and also sent to the fallback) instead of a `DisagreementError`.
174
+
196
175
  Other failures are unchanged. A sample or judge that never produces valid output raises `InvalidOutputError`, a
197
176
  failed request on the last attempt raises `LLMError`, and setup mistakes (including a provider 400 on either
198
177
  sample) raise `ConfigurationError`, which is never sent to the fallback.
178
+
179
+ ## Custom harnesses
180
+
181
+ A harness is a class that decides how to use the escalation: which samples to ask for, how to compare them, who
182
+ breaks a tie. Register one under a name and it works anywhere a built-in type does: `squishling harness: :name`,
183
+ `squish :method, harness: { type: :name, ... }`, `squish!(harness: ...)`, and `config.default_harness`. It gets
184
+ everything the built-ins get: model, provider, and params resolution, schema validation and
185
+ [`squish_validate`](failures.md#output-checks-squish_validate), retries and escalation,
186
+ [`squawk`](failures.md#observing-every-attempt-squawk) reports with token usage, concurrent samples, and the
187
+ [failure behavior](#when-a-custom-harness-fails) (`squish_fallback`, `DisagreementError`).
188
+
189
+ ### Worked example: two models and a tiebreaker
190
+
191
+ Ask two cheap models. If they agree, done. If they don't, ask a third:
192
+
193
+ ```ruby
194
+ class Tiebreaker < Squishling::Harness::Base
195
+ option :tiebreaker, :step, required: true # a model name, or a step Hash
196
+ option :compare, :proc # optional, like squishsum's compare:
197
+
198
+ def call(run, tiebreaker:, compare: nil)
199
+ raise Squishling::ConfigurationError, "#{run.label}: needs two models" if run.stages.size < 2
200
+
201
+ first, second = run.samples(run.stages.first(2)) # the escalation's first two steps, asked concurrently
202
+ return first if run.agree?(first, second, compare)
203
+
204
+ run.escalate(tiebreaker) # they differ: a third opinion
205
+ end
206
+ end
207
+
208
+ Squishling.register_harness(:tiebreaker, Tiebreaker) # e.g. in an initializer
209
+
210
+ class TicketTriager
211
+ include Squishling
212
+
213
+ squishling escalation: ["claude-haiku-4-5", "claude-sonnet-5-5"],
214
+ harness: { type: :tiebreaker, tiebreaker: "claude-opus-5-5",
215
+ compare: ->(a, b, **) { a.priority == b.priority } }
216
+ purpose "Assign a priority and a team."
217
+ output_schema do
218
+ string :priority, enum: %w[low medium high]
219
+ string :team
220
+ end
221
+ end
222
+ ```
223
+
224
+ `TicketTriager.call(...)` sends the ticket to Haiku and Sonnet at the same time. A pair that agrees on `priority` returns
225
+ Haiku's result; otherwise Opus decides. Either way you get the same typed, validated result as every other path.
226
+ `run.stages` groups identical neighbouring steps into one stage (they are one model's attempts), so this harness needs
227
+ two different models in the escalation.
228
+
229
+ For small harnesses, pass a block instead of a class. It becomes the body of a `Squishling::Harness::Base` subclass:
230
+
231
+ ```ruby
232
+ Squishling.register_harness(:best_of_three) do
233
+ def call(run)
234
+ results = run.samples([run.stages.first] * 3)
235
+ results.max_by { |result| results.count { |other| run.agree?(result, other) } } # the most-agreed-with sample
236
+ end
237
+ end
238
+ ```
239
+
240
+ ### Registering
241
+
242
+ `Squishling.register_harness(name, klass = nil, &block)` takes a `Squishling::Harness::Base` subclass or a block, and
243
+ returns the class. `Squishling.harnesses` lists every name, built-ins first.
244
+
245
+ - The registry is global, and a harness is looked up when it is **declared**, so an unknown name fails up front.
246
+ Register in an initializer, before the classes that use the name load.
247
+ - A name is lowercase letters, digits, and underscores. Registering a name twice, or one of `:escalation`,
248
+ `:squishsum`, `:judged_squishsum`, raises `ConfigurationError`.
249
+ - A class must define `call` taking the run as its first argument and each declared option as a keyword (a keyword
250
+ for an option that may be left out needs a default), and be creatable without arguments. This is checked at
251
+ registration.
252
+ - Subclass `Squishling::Harness::Base` directly. The built-in harness classes are implementation, not an extension
253
+ point; write against `Run` as they do.
254
+ - `Squishling.reset_harnesses!` forgets the registered harnesses (the built-ins stay). It is meant for specs.
255
+ `Squishling.reset_config!` leaves them, since they're registered once at boot.
256
+
257
+ ### Options
258
+
259
+ Declare each option with `option name, type, required: false`, before registering the class. They are checked when
260
+ the harness is declared, before any request is made, and an unknown, missing, or mistyped option raises
261
+ `ConfigurationError`. A `nil` counts as not given, and `call` receives only the options that were given.
262
+
263
+ | Type | Accepts | `call` receives |
264
+ |---|---|---|
265
+ | `:step` | A model name, or a Hash with `model:`, `provider:`, `params:`, `attempts:`, `forward_rejected:`, `retry_temperature:` | A normalized, frozen step; pass it to `run.escalate`, or in the Array given to `run.samples` |
266
+ | `:proc` | A Proc | The Proc |
267
+ | `:string`, `:symbol`, `:integer`, `:boolean` | A String, a Symbol (or String, converted), an Integer, `true` or `false` | The value |
268
+
269
+ An option with a shape of its own takes a callable as its type instead. It gets the value, the declaration site (a
270
+ label for error messages), and the option's name, and returns the normalized value, or raises `ConfigurationError`. The
271
+ Hashes, Arrays, and Strings in the result are copied and frozen:
272
+
273
+ ```ruby
274
+ option :word, ->(value, label, name) do
275
+ raise Squishling::ConfigurationError, "#{label}: #{name}: must be a String" unless value.is_a?(String)
276
+
277
+ value.upcase
278
+ end
279
+ ```
280
+
281
+ To check options against each other, define `def self.validate_options!(options, label)` and raise
282
+ `Squishling::ConfigurationError` (include the `label`) to reject.
283
+
284
+ ### The run
285
+
286
+ `call(run, **options)` gets a `Squishling::Run`: one squished call, with these primitives. Each returns typed
287
+ results, the same type the deterministic path returns.
288
+
289
+ | Method | What it does |
290
+ |---|---|
291
+ | `run.receiver`, `run.inputs`, `run.label` | The instance, the method's inputs, and `"Class#method"` |
292
+ | `run.steps` | The escalation, one step per attempt |
293
+ | `run.stages` | The escalation grouped by model: `[[haiku, haiku], [sonnet]]` for `[{ model: haiku, attempts: 2 }, sonnet]`. A stage is the attempts of one model |
294
+ | `run.stage(declaration)` | A stage from a model name or a step Hash (a single step is a stage of one attempt). It keeps its own provider, and its `params:` merge over the method's, as a [judge](#the-judge) step does |
295
+ | `run.escalate(*stages)` | Makes each attempt in order until an output passes the schema and `squish_validate`, in the same chat after invalid output and in a fresh one (told about the rejected output) on a new step. With no argument, it runs the escalation: exactly what `:escalation` does |
296
+ | `run.samples(stages)` | Takes an Array of stages (so `run.samples([stage, stage])`, not `run.samples(stage)`; a bare step counts as a stage of one attempt): one sample per stage, each in its own chat and retried like an escalation step, with the requests of every round sent concurrently. Returns the outputs in order. Each stage must be one model's attempts: mixed steps raise `ConfigurationError`. Samples are named `"a"`, `"b"`, ... (up to 26) |
297
+ | `run.judge(judge: nil, instructions: nil)` | Resolves a [judge](#the-judge) now (so a missing one raises `ConfigurationError` before any request) and returns it. `judge:` is a model name, or a [judge Hash](#the-judge) (`model:`, `provider:`, `params:`, `attempts:`, `type:`, `min_confidence:`; no `priority:`, `rotate:`, or `include_when:`), and `instructions:` a non-blank String or a Proc. `judge.verdict(first, second)` returns a `Squishling::Judge::Verdict` with `choice` (`:a`, `:b`, or `:neither`), `reason`, and `detail`, and `judge.step` is its first attempt. A chat judge's `reason` is model-written, so use `detail` (Squishling's own text, `nil` for a chat judge), not `reason`, in messages and logs. Without `judge:`, the judge is the escalation's second stage |
298
+ | `run.agree?(first, second, compare = nil)` | `compare` (evaluated against the instance with both results and the method's inputs) or, without one, exact equality |
299
+ | `run.log(message)` | A warning to `config.logger`, prefixed with `"[Squishling] Class#method"` |
300
+
301
+ `call` returns the accepted result: one a primitive of this run returned. Returning anything else (a Hash, `nil`, a
302
+ result you built or copied with `with`) raises `ConfigurationError`, so a harness can't skip validation.
303
+
304
+ ### When a custom harness fails
305
+
306
+ A harness fails the way the built-ins do, so `squish_fallback` and the error classes behave predictably:
307
+
308
+ - **Attempts run out.** `run.escalate` and `run.samples` raise `InvalidOutputError` (invalid output after the last
309
+ attempt) or `LLMError` (a failed request on the last attempt) themselves, with the same attributes as always.
310
+ - **Your rule rejects.** Raise one of those errors yourself. `Squishling::DisagreementError.new(candidates:,
311
+ models:)` is the one for samples that don't agree, and `Squishling::InvalidOutputError.new(errors: [...],
312
+ source: "Harness")` for anything else. Both go to [`squish_fallback`](failures.md#fallbacks). A String error is
313
+ tagged `:squishling` in [`error_details`](failures.md#telling-the-entries-apart); pass a
314
+ `Squishling::InvalidOutputError::Detail` to tag it yourself.
315
+ - **Setup mistakes** raise `ConfigurationError`, which is never retried and never sent to the fallback.
316
+ - **Your own exceptions** (from `call`, `compare:`, or `squish_validate`) propagate unchanged. Squishling doesn't wrap or
317
+ swallow them.
318
+
319
+ Keep model output out of the messages you raise and the lines you `run.log`: it can quote the input. The output is
320
+ available to your code as the results and in `InvalidOutputError#raw`, and to `squawk` if you opt in.
321
+
322
+ ### Reporting to `squawk`
323
+
324
+ Every attempt a primitive makes is reported to the [`squawk`](failures.md#observing-every-attempt-squawk) hook
325
+ (unless narrowed with `squawk_on:`), with usage, so [token measuring](measuring-tokens.md) works unchanged. `metadata[:role]` says what the attempt was for:
326
+ `nil` for an escalation attempt, `:sample` (with `metadata[:sample]` set to `"a"`, `"b"`, ...), or `:judge`. `attempt`
327
+ and `attempts` count within that sample, judge, or escalation. `metadata[:repeated]` is `true` for an attempt that
328
+ sent back the reply it was just rejected for; the primitive then skips the rest of that step's attempts (the next
329
+ step's `attempt` jumps past them, and a sample ends with its `InvalidOutputError`).
330
+
331
+ ### Threads
332
+
333
+ Only the provider requests inside `run.samples` run on worker threads. Your `call`, parsing, validation,
334
+ `squish_validate`, and `compare:` all run on the calling thread, and the routing state stays fiber-local. Call a run's
335
+ primitives from the thread `call` runs on: from another one they raise `ConfigurationError`. Don't keep unsynchronized
336
+ state at class level in a harness, since one instance is created per call but the class is shared.
@@ -6,11 +6,13 @@ first tells you when the second is worth paying.
6
6
  ## What Squishling gives you
7
7
 
8
8
  - `result.squished?` says which path served a call, so you can count LLM calls per class.
9
- - The [`squawk` hook](failures.md#observing-every-attempt-squawk) runs after every LLM attempt and receives the
9
+ - The [`squawk` hook](failures.md#observing-every-attempt-squawk) runs after every LLM attempt (by default; see `squawk_on` below) and receives the
10
10
  method (`label`, e.g. `"ShipmentUpdate#call"`), the model and provider, which attempt it was, and the token usage
11
- RubyLLM reported. That is enough to attribute spend to a squishling method without any other tooling.
12
- - Cost in dollars comes from RubyLLM's model pricing. Multiply the reported tokens by your model's price, or read it
13
- from RubyLLM directly (see [the instrumenter](#lower-level-rubyllms-instrumenter)).
11
+ the adapter reported. That is enough to attribute spend to a squishling method without any other tooling.
12
+ - A [token budget](failures.md#capping-a-calls-cost-token-budgets) (`max_total_output_tokens:`, `max_total_input_tokens:`)
13
+ caps what a single call may use across all its attempts, so a call that keeps failing can't spend without limit.
14
+ - Cost in dollars isn't reported by Squishling's adapters. Multiply the reported tokens by your model's price, or, with
15
+ the RubyLLM adapter, read it from RubyLLM directly (see [OpenTelemetry](#opentelemetry), or RubyLLM's `config.instrumenter`).
14
16
 
15
17
  ## Deciding when to write the code
16
18
 
@@ -31,8 +33,8 @@ stays small, the debt you haven't taken on is cheaper than the code.
31
33
  |---|---|---|
32
34
  | [Coolhand Labs](#coolhand-labs) | Picking up your squishling calls automatically and measuring their cost and accuracy | The `coolhand` gem and an initializer |
33
35
  | [Custom: the `squawk` hook](#custom-the-squawk-hook) | Logging or metrics in your own stack, with no new dependency | A lambda and one config line |
34
- | [OpenTelemetry](#opentelemetry) | Sending traces to Datadog, Langfuse, Arize, Braintrust, LangSmith, or any OTel backend | The OpenTelemetry SDK, an exporter, and one line |
35
- | [LangSmith](#langsmith) | Browsing traces and token usage in LangSmith | Through OpenTelemetry |
36
+ | [Your own observer](#your-own-observer) | Wrapping each request in your tracing or metrics tool, knowing which method, level, and sample it was | An object with `around(call)` |
37
+ | [OpenTelemetry](#opentelemetry) | Sending traces to Datadog, Langfuse, Arize, Braintrust, LangSmith, or any OTel backend | RubyLLM's tracer and an exporter |
36
38
 
37
39
  ### Coolhand Labs
38
40
 
@@ -51,12 +53,13 @@ end
51
53
  ```
52
54
 
53
55
  Get an API key at [coolhandlabs.com](https://coolhandlabs.com/). To keep the data in your own infrastructure, point
54
- `config.base_url` at a Coolhand-compatible endpoint of your own.
56
+ `config.base_url` at a Coolhand-compatible endpoint of your own. Squishling also tells Coolhand which workload and
57
+ template each call belongs to; see [Coolhand Labs](coolhand.md).
55
58
 
56
59
  ### Custom: the `squawk` hook
57
60
 
58
- `config.squawk` runs after every LLM attempt, accepted or rejected, and is handed the method's label, the model,
59
- and the token usage RubyLLM reported. A lambda can take just the keywords it needs:
61
+ `config.squawk` runs after every LLM attempt, accepted or rejected (unless you narrow it with `squawk_on:`, which makes token totals undercount, so keep `:all` for measuring), and is handed the method's label, the model,
62
+ and the token usage the adapter reported. A lambda can take just the keywords it needs:
60
63
 
61
64
  ```ruby
62
65
  Squishling.configure do |config|
@@ -73,43 +76,39 @@ Each line carries the `"Class#method"` that made the call, so grouping by label
73
76
  that stays in Ruby makes no LLM request and never reaches the hook, so a label that stops appearing is a path that has
74
77
  been hardened. You can also set the hook per class (`squishling squawk: ...`) or per method. See
75
78
  [Observing every attempt](failures.md#observing-every-attempt-squawk) for every keyword and the rules for exceptions.
79
+ Under a [harness](harnesses.md), including a [custom one](harnesses.md#custom-harnesses), every sample and judge
80
+ request reaches the hook too. `metadata[:role]` (`nil`, `:sample`, or `:judge`) and `metadata[:sample]` (`"a"`, `"b"`, ...)
81
+ split the spend by what each request was for.
76
82
 
77
- ### Lower level: RubyLLM's instrumenter
83
+ ### Your own observer
78
84
 
79
- To meter every RubyLLM request in your app, not only Squishling's, use RubyLLM's own instrumentation. It reports a
80
- `usage.ruby_llm` event for each provider request, with `model`, `provider`, `status`, `tokens` (`input`, `output`,
81
- and more) and `cost`. Give it an object that responds to `instrument(name, payload)` and optionally takes a block. In
82
- Rails, `ActiveSupport::Notifications` is used automatically.
85
+ An [observer](observers.md) wraps each provider request and gets the request's token usage back from `yield`, so it
86
+ can meter spend knowing which method, escalation level, sample, or judge made the request. Any object that responds to
87
+ `around(call)` is one:
83
88
 
84
89
  ```ruby
85
- class TokenMeter
86
- def instrument(name, payload = {})
87
- if name == "usage.ruby_llm"
88
- Rails.logger.info("llm model=#{payload[:model]} workflow=#{payload[:workflow_name]} " \
89
- "in=#{payload[:tokens].input} out=#{payload[:tokens].output}")
90
- end
91
- block_given? ? yield : nil
90
+ class SpendObserver
91
+ def around(call)
92
+ usage = yield # input_tokens, output_tokens (nil if unreported), and model; never the content
93
+ tags = ["label:#{call.label}", "role:#{call.role || 'squish'}", "model:#{usage.model}"]
94
+ Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags:)
95
+ Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags:)
92
96
  end
93
97
  end
94
98
 
95
- RubyLLM.configure { |config| config.instrumenter = TokenMeter.new }
99
+ Squishling.configure { |config| config.observer = SpendObserver.new }
96
100
  ```
97
101
 
98
- These events don't know which squishling made the request. To attribute the spend, wrap the call in a RubyLLM
99
- workflow, and every event emitted inside the block carries its name, id and any `metadata:` you pass. (The
100
- `squawk` hook above needs no wrapping.)
101
-
102
- ```ruby
103
- RubyLLM.workflow("ShipmentUpdate", metadata: { carrier: "acme-freight" }) do
104
- ShipmentUpdate.call(carrier: "acme-freight", payload: body)
105
- end
106
- ```
102
+ An observer sees usage, never the model's output or your inputs. See [Observers](observers.md) for the `call` fields
103
+ and the rules.
107
104
 
108
105
  ### OpenTelemetry
109
106
 
110
107
  RubyLLM 2.1+ ships its own OpenTelemetry tracer. Each request becomes a span with the model, request settings and
111
108
  token usage (`gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens`), following the OpenTelemetry GenAI
112
- conventions. It does not record message text. Your app owns the SDK and exporters.
109
+ conventions. It does not record message text. Your app owns the SDK and exporters, and the spans work with any OTLP
110
+ backend, [LangSmith](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
111
+ included.
113
112
 
114
113
  ```ruby
115
114
  # Gemfile
@@ -121,16 +120,9 @@ OpenTelemetry::SDK.configure # reads the standard OTEL_* environment vari
121
120
  RubyLLM::OpenTelemetry.enable # starts tracing RubyLLM requests
122
121
  ```
123
122
 
124
- Spans don't say which squishling made the request, so pair this with a `RubyLLM.workflow` (see
125
- [above](#lower-level-rubyllms-instrumenter)) or group by model and prompt in your backend.
126
-
127
- ### LangSmith
128
-
129
- LangSmith accepts OpenTelemetry traces, so the OpenTelemetry setup above covers it: point the OTLP exporter at
130
- LangSmith. See LangSmith's
131
- [OpenTelemetry docs](https://docs.smith.langchain.com/observability/how_to_guides/tracing/trace_with_opentelemetry)
132
- for the endpoint and headers. A community Ruby gem, [`langsmith-sdk`](https://github.com/felipekb/langsmith-ruby-sdk),
133
- also exists; it isn't published by LangChain.
123
+ Spans don't say which squishling made the request. To attribute them, wrap the call in `RubyLLM.workflow("Name")`, or
124
+ use an [observer](observers.md) that adds the label to the current span. RubyLLM's `config.instrumenter` also reports
125
+ a `usage.ruby_llm` event per request, with tokens and cost, if you would rather meter than trace.
134
126
 
135
127
  ## Privacy
136
128
 
@@ -144,5 +136,7 @@ append. Tools that sit on the request path are a separate route out of your proc
144
136
  send them. That is the sanctioned route for model output; see
145
137
  [Observing every attempt](failures.md#observing-every-attempt-squawk). The examples above log only metadata.
146
138
  - **OpenTelemetry** with RubyLLM's tracer records usage and request settings, not message text.
139
+ - **An observer** sees timing, token usage, and the call's metadata (method, model, level), never the model's output or
140
+ your inputs.
147
141
 
148
142
  Check what your tool stores before pointing it at inputs that contain personal data.
data/docs/observers.md ADDED
@@ -0,0 +1,80 @@
1
+ # Observers
2
+
3
+ An observer wraps every provider request Squishling makes, so another tool can see it while it happens: open a
4
+ tracing span, tag the request, set a thread-local. It is the around-style counterpart to [`squawk`](failures.md#observing-every-attempt-squawk),
5
+ which reports each attempt only after it is over.
6
+
7
+ Squishling ships one, for [Coolhand Labs](coolhand.md), and uses it automatically when the `coolhand` gem is configured.
8
+ Any object that responds to `around(call)` is an observer:
9
+
10
+ ```ruby
11
+ class TracingObserver
12
+ def around(call)
13
+ Tracer.in_span("squishling #{call.label}", attributes: { "model" => call.model, "role" => call.role.to_s }) do
14
+ yield
15
+ end
16
+ end
17
+ end
18
+
19
+ Squishling.configure { |config| config.observer = TracingObserver.new }
20
+ ```
21
+
22
+ `around` must yield exactly once. The block makes the request, so the observer runs around it, on the thread that
23
+ makes it: for the concurrent samples of the squishsum harnesses, that is a worker thread, so thread-locals set by the
24
+ observer are visible to the HTTP call. What `around` returns is ignored. One that never yields, or yields twice,
25
+ raises `ConfigurationError`.
26
+
27
+ ## What `yield` returns
28
+
29
+ The block returns a `Squishling::Observer::Usage`: the request's `input_tokens` and `output_tokens` (nil when the
30
+ provider didn't report them) and the `model` that answered. That is enough to meter spend:
31
+
32
+ ```ruby
33
+ class SpendObserver
34
+ def around(call)
35
+ usage = yield
36
+ Metrics.increment("squishling.tokens.in", usage.input_tokens.to_i, tags: ["label:#{call.label}"])
37
+ Metrics.increment("squishling.tokens.out", usage.output_tokens.to_i, tags: ["label:#{call.label}"])
38
+ end
39
+ end
40
+ ```
41
+
42
+ Usage never includes the model's output; Squishling returns the real response to its own caller, whatever `around`
43
+ returns. If the request fails, the error is raised through `yield`, so a `begin/ensure` in `around` still runs.
44
+
45
+ ## `config.observer`
46
+
47
+ | Value | Meaning |
48
+ |---|---|
49
+ | `nil` (default) | The Coolhand observer when `coolhand` is loaded and configured, otherwise none |
50
+ | `:coolhand` | The Coolhand observer, always. Raises `ConfigurationError` if the gem is missing or too old |
51
+ | any object responding to `around` | That observer |
52
+ | `false` | None, even when Coolhand is configured |
53
+
54
+ Anything else raises `ConfigurationError` when you assign it.
55
+
56
+ ## The `call`
57
+
58
+ `around` receives a `Squishling::Observer::Call` (a `Data`) describing the request:
59
+
60
+ | Field | |
61
+ |---|---|
62
+ | `label` | `"Class#method"` |
63
+ | `purpose` | The purpose text sent as the system prompt (after `purpose` procs and `append_to_purpose` sections are resolved) |
64
+ | `handed_off` | `true` when a `squish!` call sent the call to the LLM (typically from a `rescue`), `false` when `squish_when` or a missing implementation did |
65
+ | `harness` | The harness type (`:escalation`, `:squishsum`, `:judged_squishsum`, or a [custom one](harnesses.md#custom-harnesses)) |
66
+ | `role` | `nil` for an escalation attempt, `:sample` or `:judge`, as in `squawk`'s `metadata` |
67
+ | `sample` | `"a"`, `"b"`, ... for a sample, else `nil` |
68
+ | `attempt`, `attempts` | This request's 1-based position in the steps it was asked on, and how many there are: the escalation's expanded steps (or, for a custom harness, those passed to `run.escalate`), the sample's stage, or the judge's steps |
69
+ | `model`, `provider` | The step's model and provider (`provider` is `nil` unless declared) |
70
+ | `request` | `:chat`, or `:judgment` for a System One judge |
71
+
72
+ It carries metadata only. The method's inputs, its context, and the model's output never reach an observer: it gets
73
+ the request's token [`Usage`](#what-yield-returns) back, and `squawk` is the route for output.
74
+
75
+ ## Errors
76
+
77
+ An observer is your code: an exception it raises propagates unchanged, and is never retried, escalated, or handed to
78
+ `squish_fallback`. Squishling maps a failed request onto `LLMError` or `ConfigurationError` inside the block, so `around` sees those and
79
+ can rescue them or let them pass. If `around` rescues one and returns normally, Squishling raises it again: an
80
+ observer can watch a failure, not swallow it.
data/docs/routing.md CHANGED
@@ -61,6 +61,9 @@ This is the workflow from [Elastic Software](https://everythingengineer.substack
61
61
 
62
62
  Callers never change, because both paths return the same result class.
63
63
 
64
+ To keep a particular model off certain inputs (a client that needs a BAA, say), give its escalation step an
65
+ `include_when:` Proc; see [Excluding a model per call](configuration.md#excluding-a-model-per-call).
66
+
64
67
  ## Entry points
65
68
 
66
69
  Squish methods other than `call` with `squish`, optionally overriding the class-level settings per method:
@@ -90,7 +93,8 @@ end
90
93
  - `append_to_purpose:` (added to the class's, see [Appending to the purpose](#appending-to-the-purpose))
91
94
  - `output_schema:` (or a schema block)
92
95
  - `model:` or `escalation:`, `provider:`, and `params:` (generation params; see [Configuration](configuration.md))
93
- - `harness:` (see [Harnesses](harnesses.md))
96
+ - `harness:` (see [Harnesses](harnesses.md), including your own)
97
+ - `max_total_output_tokens:` and `max_total_input_tokens:` (see [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets))
94
98
  - `when:`, a predicate proc
95
99
  - `validate:` and `fallback:` (see [Failure handling](failures.md))
96
100
 
@@ -134,6 +138,7 @@ end
134
138
  | `purpose:` | Replaces the purpose |
135
139
  | `model:` or `escalation:`, `provider:`, `params:` | E.g. send this call to a stronger model, or a whole [escalation](configuration.md#models-and-escalation), when Ruby fails. A `provider:` needs a `model:` or `escalation:`; `params:` merge key by key over the declared ones. |
136
140
  | `harness:` | Replaces the declared [harness](harnesses.md), e.g. `harness: :judged_squishsum` to double-check a hand-off |
141
+ | `max_total_output_tokens:`, `max_total_input_tokens:` | Cap what this call may spend, e.g. a tighter budget for a hand-off; `false` lifts a declared cap. See [Capping a call's cost](failures.md#capping-a-calls-cost-token-budgets). |
137
142
 
138
143
  - **The output schema can't be overridden.** The call still returns the method's result type.
139
144
  - **Failures** go through the normal LLM path: a declared `squish_fallback` is used, otherwise
@@ -210,7 +215,7 @@ Source is read with Ruby's own parser (Prism) the first time it's needed and cac
210
215
  "context": { "customer_tier": "enterprise", "product": "API" } }
211
216
  ```
212
217
 
213
- - **Squishsum and ensemble harnesses:** both samples get exactly this input, and a [judge](harnesses.md#the-judge) gets
218
+ - **Squishsum harnesses:** both samples get exactly this input, and a [judge](harnesses.md#the-judge) gets
214
219
  the same purpose and input plus both samples' outputs.
215
220
  - **Retries and escalation:** another attempt of the same step gets the validation errors in the same conversation. A
216
221
  later [escalation](configuration.md#models-and-escalation) step, which may be a different provider (say, local
data/docs/schemas.md CHANGED
@@ -30,10 +30,29 @@ output_schema(
30
30
  )
31
31
  ```
32
32
 
33
+ ## Describing fields
34
+
35
+ Every DSL type, and `optional`, takes a `description:`. It is sent to the provider as guidance for that field
36
+ and isn't enforced; validation is by type and shape only.
37
+
38
+ **Best practice:** put per-field guidance here rather than in the `purpose`. The description travels with the
39
+ field in the schema the model fills in, so the instruction sits right where the value goes and next to the type
40
+ or `enum` it explains. The purpose can then stay short and about the goal. For rules that must hold, use an
41
+ `enum` or `squish_validate`; a description is a request, not a guarantee.
42
+
43
+ ```ruby
44
+ output_schema do
45
+ string :language, description: "Lowercase language name, e.g. 'ruby'."
46
+ optional(:regex, description: "The proposed regex source, without delimiters or flags.") { string }
47
+ end
48
+ ```
49
+
33
50
  ## Strict schemas only
34
51
 
35
- Squishling only supports **strict** output schemas: it always asks RubyLLM for strict structured output, and a
36
- schema declaring `strict: false` raises `Squishling::ConfigurationError`. RubyLLM 2.0 on its own would send a
52
+ Squishling only supports **strict** output schemas: it always asks the adapter for strict structured output (the
53
+ OpenAI-compatible adapter's `structured_output: :json_object` and `:none` modes are the explicit exception, for servers
54
+ that lack it, where the schema goes into the prompt instead), and a schema declaring `strict: false` raises
55
+ `Squishling::ConfigurationError`. Squishling validates every answer against the schema either way. RubyLLM 2.0 on its own would send a
37
56
  schema with optional properties non-strict; Squishling always sets the flag explicitly, so it stays strict.
38
57
 
39
58
  With raw hashes, follow your provider's strict-mode rules (e.g. list every property in `required` and set
@@ -58,6 +77,13 @@ r.to_h # deep Hash with symbol keys
58
77
  r.squished? # => true when it came from the LLM
59
78
  ```
60
79
 
80
+ A property can't share a name with a method the result depends on, because the `Data` member would replace it.
81
+ Squishling raises `ConfigurationError` when the schema is built (before any request), naming the clashing
82
+ fields; rename them (`class` to `ticket_class`). The reserved names are `class`, `is_a?`, `kind_of?`, `hash`, `inspect`, `to_s`,
83
+ `to_h`, `members`, `with`, `dup`, `send`, `public_send`, `eql?`, `==`, `[]`, `squished?`, `object_id`,
84
+ `deconstruct_keys`, and a few Ruby and Squishling internals (`initialize*`, `__send__`, `squishling_squished`).
85
+ Other names, such as `method`, `format`, or `type`, work as usual. This applies to nested objects and array items too.
86
+
61
87
  On the deterministic path, return a `Hash` or build the result with `result(...)`. Either way it's validated
62
88
  against the schema, and so is anything else you return (`nil`, a string, another schema's result), which raises
63
89
  `Squishling::InvalidOutputError` unless it matches.
@@ -70,7 +96,7 @@ field nullable with the DSL's `optional`. The key stays required, but its value
70
96
  ```ruby
71
97
  output_schema do
72
98
  array :symptoms, of: :string # always a list; [] means "none"
73
- optional :allergies do # [] means "none", nil means "not mentioned"
99
+ optional :allergies, description: "[] if the record says none; null if it doesn't mention allergies" do
74
100
  array of: :string
75
101
  end
76
102
  optional :vitals do # a typed Data object, or nil
@@ -84,8 +110,8 @@ end
84
110
  `optional` produces `anyOf: [<schema>, {type: "null"}]`, which is strict-compatible. Squishling types the non-null
85
111
  branch as usual: `vitals` is a `Data` object or `nil`, and a list of objects is a list of `Data` objects. In a raw
86
112
  JSON Schema, `type: ["array", "null"]` works too. A union with more than one non-null branch is ambiguous, so its
87
- values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in your
88
- purpose.
113
+ values come back as plain hashes. If you need the model to tell "none" apart from "not mentioned", say so in the field's
114
+ `description:`, as `allergies` does above.
89
115
 
90
116
  ## Contracts beyond the schema
91
117
 
@@ -111,7 +137,8 @@ Providers' strict modes don't support conditional keywords (`if`/`then`/`else` f
111
137
  `dependentSchemas` from `dependent`), so Squishling keeps them out of the schema it sends and enforces them itself.
112
138
  Output that breaks a rule is rejected like any other invalid output: the call moves on to the next attempt in its
113
139
  [escalation](configuration.md#models-and-escalation) with the errors. The model doesn't see these rules
114
- up front, so state them in your purpose too.
140
+ up front, so state them in the `description:` of the field they govern (here `status`) or in your purpose. A
141
+ description declared inside a `given` block is local-only too, so it is never sent.
115
142
 
116
143
  For anything a schema can't express (sums, lookups against your data), use
117
144
  [`squish_validate`](failures.md#output-checks-squish_validate).