quicopt 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3149e149b55706102ba346c10b6768e8cfbab47977aaf6ca979cb388c4ee1b9
4
- data.tar.gz: 88307afbd6a77015173c066bf0f1162010f041c72a5666549e9e3be013dda446
3
+ metadata.gz: 370fa0c8906d43f509f9262406805bdc0e1357248e8271125b6b76a096a2fd6f
4
+ data.tar.gz: 0df9e60f7456ae1b0ecae3a9d3ffb668490c684a095e71f4ebfb614f522c15f0
5
5
  SHA512:
6
- metadata.gz: 6c4844cbe4e9b8d1577125bc9671ffb0a6a6993d2d18570579d82ef3ca412427bcd854a7e925e75f0ca85473277e895a2c2b9b08487c3cdfe7b7a5af20bb0226
7
- data.tar.gz: d78fc525172e40e19a47b4eec7811b34ba843d9a43a21ecac3609f7a366b4f3aa65cecb660891587fea5a02a69362a48ce6e9f35b356bae1cddcdabcbc0ee12a
6
+ metadata.gz: d4d0c6ef70c1f502da08557c366f31695293a52886b72bc6f214d89c902eedb21862dbe3a14223184adaa3eb64873dccf52a54609ce836e4363f92f77ef9344b
7
+ data.tar.gz: e1d1ac282698f36bbe968c9099029db5f3e12bbbe6fcb7fded6dd83dfe1e49b66bdba22e267ca64845deb12cfd6d93adc24b434066470417dfbad162deeeb40f
data/.yardopts CHANGED
@@ -4,6 +4,7 @@
4
4
  --no-private
5
5
  --hide-void-return
6
6
  --exclude lib/quicopt/modeler
7
- lib/**/*.rb
7
+ lib/quicopt/**/*.rb
8
+ lib/quicopt.rb
8
9
  -
9
10
  LICENSE
data/README.md CHANGED
@@ -1,13 +1,36 @@
1
1
  # quicopt
2
2
 
3
- A Ruby client for the Quicopt optimization service.
4
-
5
- It does exactly three things: author an optimization model in a small native Ruby
6
- DSL, encode it to the versioned Quicopt wire IR, and send it to the service. It is
7
- the Ruby peer of the Python and Julia clients — the same wire contract, the same
8
- results.
9
-
10
- There is no local solver here. The service does the solving.
3
+ [![Gem](https://img.shields.io/gem/v/quicopt.svg)](https://rubygems.org/gems/quicopt)
4
+ [![Ruby 3.2+](https://img.shields.io/badge/ruby-3.2%2B-CC342D.svg)](https://rubygems.org/gems/quicopt)
5
+ [![Docs](https://img.shields.io/badge/docs-latest-blue.svg)](https://quicopt.github.io/quicopt-ruby/)
6
+ [![License: Apache 2.0](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](LICENSE)
7
+
8
+ A Ruby client for the [Quicopt](https://quicopt.com) optimization service.
9
+
10
+ You describe a decision: what you get to choose, what has to hold, and what you
11
+ want as much (or as little) of as possible. Quicopt finds the best choice there is.
12
+
13
+ The gem does three things: it lets you write that description in plain Ruby, packs
14
+ it into a compact message, and sends it to the service, which solves it and sends
15
+ the answer back. It is the Ruby counterpart of the Python and Julia clients, and
16
+ all three send the same message — so the same model gets the same answer whichever
17
+ language wrote it.
18
+
19
+ There is no solver on your machine. The service does the solving.
20
+
21
+ ## Documentation
22
+
23
+ - **[API reference](https://quicopt.github.io/quicopt-ruby/)** — every public class
24
+ and method, with examples.
25
+ - [`Quicopt::Model`](https://quicopt.github.io/quicopt-ruby/Quicopt/Model.html) —
26
+ variables, constraints, objective.
27
+ - [`Quicopt::Permutation`](https://quicopt.github.io/quicopt-ruby/Quicopt/Permutation.html)
28
+ and [`Quicopt::LookupTable`](https://quicopt.github.io/quicopt-ruby/Quicopt/LookupTable.html) —
29
+ orders as decisions, and data read along them.
30
+ - [`Quicopt::Client`](https://quicopt.github.io/quicopt-ruby/Quicopt/Client.html) —
31
+ sending a model, API keys, long-running jobs, errors.
32
+ - [`Quicopt::Result`](https://quicopt.github.io/quicopt-ruby/Quicopt/Result.html) —
33
+ what comes back.
11
34
 
12
35
  ## Install
13
36
 
@@ -21,8 +44,8 @@ or
21
44
  gem install quicopt
22
45
  ```
23
46
 
24
- Ruby 3.2 or newer. The only runtime dependency is `google-protobuf`; the transport
25
- is stdlib `net/http`.
47
+ Ruby 3.2 or newer. The only library it needs at runtime is `google-protobuf`;
48
+ requests go out over Ruby's built-in `net/http`.
26
49
 
27
50
  ## Quick start
28
51
 
@@ -52,7 +75,109 @@ result.solution # {"x" => 4.0, "y" => 1.0, "z" => 10.0}
52
75
  result.solve_time_seconds
53
76
  ```
54
77
 
55
- ## The model
78
+ ## A first realistic example: Sourcing & Procurement
79
+
80
+ Suppose you are running a webshop for electronic parts. A customer wants to order 200 *resistor packs*, 150 *terminal blocks* and 95 *sensor modules*. You have three suppliers (*Nordwind*, *BitBazaar* and *CircuitCellar*) who each offer all of these parts at different price tags. We can express this as
81
+
82
+ ```ruby
83
+ parts = %w[resistor_pack terminal_block sensor_module]
84
+ demand = { "resistor_pack" => 200, "terminal_block" => 150, "sensor_module" => 95 }
85
+
86
+ suppliers = %w[Nordwind BitBazaar CircuitCellar]
87
+ price = {
88
+ ["Nordwind", "resistor_pack"] => 1.00, ["BitBazaar", "terminal_block"] => 1.50, ["CircuitCellar", "sensor_module"] => 2.90,
89
+ ["BitBazaar", "resistor_pack"] => 1.15, ["Nordwind", "terminal_block"] => 1.90, ["Nordwind", "sensor_module"] => 3.30,
90
+ ["CircuitCellar", "resistor_pack"] => 1.30, ["CircuitCellar", "terminal_block"] => 2.10, ["BitBazaar", "sensor_module"] => 3.50,
91
+ }
92
+ ```
93
+ So far, so simple. Each supplier also has shipping fees as well as a "free-shipping threshold", which we can write as
94
+ ```ruby
95
+ ship_fee = { "Nordwind" => 40.0, "BitBazaar" => 40.0, "CircuitCellar" => 40.0 }
96
+ free_above = { "Nordwind" => 300.0, "BitBazaar" => 300.0, "CircuitCellar" => 300.0 }
97
+ ```
98
+ It's obvious what to order where, right? Picking the cheapest option for each part, we pay
99
+ ```ruby
100
+ demand["resistor_pack"] * price[["Nordwind", "resistor_pack"]] +
101
+ demand["terminal_block"] * price[["BitBazaar", "terminal_block"]] +
102
+ demand["sensor_module"] * price[["CircuitCellar", "sensor_module"]] +
103
+ ship_fee.values.sum # == 820.5
104
+ ```
105
+ Let's check what a professional optimization solver has to say - we have `Quicopt` at hand:
106
+ ```ruby
107
+ # new model
108
+ m = Quicopt::Model.new
109
+ ```
110
+
111
+ ### Variables
112
+
113
+ ```ruby
114
+ # empty hash literal to hold "quantity of part p ordered from supplier s"
115
+ order_qty = {}
116
+
117
+ # order quantities: integers with lower bounds 0 and no (nil) upper bounds
118
+ suppliers.each { |s| parts.each { |p| order_qty[[s, p]] = m.int_var(0, nil, "x_#{s}_#{p}") } }
119
+
120
+ # binary usage variables: are we using supplier s?
121
+ use = suppliers.to_h { |s| [s, m.bin_var("use_#{s}")] }
122
+
123
+ # binary free-shipping variables: have we ordered enough to qualify?
124
+ free = suppliers.to_h { |s| [s, m.bin_var("free_#{s}")] }
125
+
126
+ # define a helper expression (sum over price times order quantity)
127
+ cart_value = suppliers.to_h { |s| [s, parts.sum { |p| price[[s, p]] * order_qty[[s, p]] }] }
128
+ ```
129
+
130
+ ### Constraints & Objective
131
+
132
+ ```ruby
133
+ # demand satisfaction: a constraint to enforce that the ordered quantity is AT LEAST the demand
134
+ parts.each { |p| m.add(suppliers.sum { |s| order_qty[[s, p]] } >= demand[p]) }
135
+
136
+ # introduce a "large" number to enfore a so-called "big-M" constraint
137
+ big = demand.values.sum * 2
138
+ # => as soon as order_qty is NOT zero, use is forced to one
139
+ suppliers.each { |s| parts.each { |p| m.add(order_qty[[s, p]] <= big * use[s]) } }
140
+
141
+ # free-shipping binaries must be zero when use is zero
142
+ suppliers.each { |s| m.add(free[s] <= use[s]) }
143
+
144
+ # since free occurs with a minus in the objective below,
145
+ # it will be set to one if not forced to zero as long as free_above is not cleared
146
+ suppliers.each { |s| m.add(free_above[s] * free[s] <= cart_value[s]) }
147
+
148
+ # our cost objective function to be minimized
149
+ # the shipping fee is not charged if both use and free are one
150
+ m.minimize(suppliers.sum { |s| cart_value[s] + ship_fee[s] * (use[s] - free[s]) })
151
+ ```
152
+ Now all we have to do is to send this to the Quicopt API service:
153
+ ```ruby
154
+ # get the results from the API service
155
+ result = Quicopt::Client.new.solve(m)
156
+
157
+ # inspect the result
158
+ puts result.display
159
+ ```
160
+
161
+ ```
162
+ ▄▀▀▀▄ ▄ █
163
+ █ █ █ █ ▄▄ ▄▀▀▀▄ ▄▀▀▀▄ █▀▀▀▄ ▀█▀▀
164
+ █ ▀▄▀ █ █ █ █ ▄ █ █ █▄▄▄▀ █ ▄
165
+ ▀▀ ▀ ▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ █ ▀▀
166
+ ├── status: optimal
167
+ ├── feasible: true
168
+ ├── objective: 733.35
169
+ ├── x: free_BitBazaar=1, free_CircuitCellar=1, free_Nordwind=0, use_BitBazaar=1, use_CircuitCellar=1, use_Nordwind=0, … (15 variables)
170
+ └── solve_time: 0.0497 s
171
+ ```
172
+ But wait, it says `objective: 733.35`! We have *saved* `87.15`, or more than 10%! How have we achieved that? The solver gave us `use_Nordwind=0`, i.e. we are *not* using *Nordwind* at all. Looking at the other variables via `puts result.solution`, we find
173
+ ```ruby
174
+ "x_BitBazaar_resistor_pack" => 181.0,
175
+ "x_CircuitCellar_resistor_pack" => 19.0
176
+ ```
177
+ So we aren't buying *any* resistor packs at the *cheapest* supplier! The catch is, of course, that splitting the resistor packs in this way, we save the shipping fees *twice*. While one could still figure this out on a piece of paper for this small example, that does not scale to real scenarios. `Quicopt` does: try it out on [quicopt.com](https://quicopt.com).
178
+
179
+
180
+ ## Building a model
56
181
 
57
182
  ### Variables
58
183
 
@@ -68,8 +193,8 @@ it, so naming your variables is worth it. `start:` sets the initial point; it
68
193
  defaults to 0 clamped into the bounds.
69
194
 
70
195
  Integer variables with bounds of exactly `[0, 1]` are declared binary — the same
71
- normalisation the other clients apply, so one model reaches the service as one
72
- program whichever language authored it.
196
+ rule the Python and Julia clients apply, so the same model means the same thing
197
+ whichever language wrote it.
73
198
 
74
199
  ### Expressions
75
200
 
@@ -83,16 +208,16 @@ z**2 # quadratic
83
208
  x / 2 # division by a constant
84
209
  ```
85
210
 
86
- Expressions are normalised as they are built — a constant plus linear terms plus
211
+ Like terms are collected as you write them — a constant, plus linear terms, plus
87
212
  quadratic terms — so `x * y` and `y * x` are the same term, and `x - x` is zero.
88
213
 
89
- Only linear and quadratic expressions exist. Anything beyond that raises at the
90
- point you write it, rather than being silently dropped or approximated:
214
+ Only linear and quadratic expressions exist. Anything beyond that raises where you
215
+ wrote it, rather than being silently dropped or approximated:
91
216
 
92
217
  ```ruby
93
- x * y * z # Quicopt::UnsupportedExpression: exceeds the quadratic subset
94
- x / y # Quicopt::UnsupportedExpression: division by a variable
95
- x**3 # Quicopt::UnsupportedExpression
218
+ x * y * z # Quicopt::UnsupportedExpression — that would be cubic
219
+ x / y # Quicopt::UnsupportedExpression — you can only divide by a number
220
+ x**3 # Quicopt::UnsupportedExpression — exponents are 0, 1 or 2
96
221
  ```
97
222
 
98
223
  ### Constraints
@@ -110,31 +235,126 @@ Two consequences of that, both deliberate:
110
235
  - `x == 5` is a row. Comparing a variable to something that could never be part
111
236
  of an expression (`x == nil`) still answers `false`, and variables remain
112
237
  usable as Hash and Set keys, but do not expect `==` to test equality.
113
- - `!=` has no wire form and raises rather than quietly answering `false`.
238
+ - `!=` is not a constraint Quicopt can express, so it raises rather than quietly
239
+ answering `false`.
114
240
  - A number on the left works (`5 <= x`), because Ruby hands the comparison back
115
241
  to the expression. `5 == x`, however, is Ruby's own `Integer#==` and is just
116
242
  `false` — write `x == 5`.
117
243
 
118
244
  Chained comparisons (`1 <= x <= 5`) are not a thing in Ruby. Add two rows.
119
245
 
120
- ## Working with the wire bytes
246
+ ## Choosing an order
247
+
248
+ Some decisions are orders: the route a van drives through its stops, or the sequence in which a machine works through its jobs. Declare such an order with `permutation`, and read data along it with a lookup table. Here are five stops along a road; find the shortest route through all of them that visits stop 3 before stop 0:
121
249
 
122
- The encoded program is not hidden. Inspect it, cache it, or route it yourself:
250
+ ```ruby
251
+ position = [0, 3, 1, 4, 2] # km along the road, stops 0 to 4
252
+
253
+ m = Quicopt::Model.new
254
+ dist = m.lookup_table("dist", position.map { |a| position.map { |b| (a - b).abs } })
255
+ route = m.permutation("route", 5)
256
+ route.precede(3, 0) # stop 3 is visited before stop 0
257
+ m.minimize((0...4).sum { |k| dist[route.item_at(k), route.item_at(k + 1)] })
258
+
259
+ result = Quicopt::Client.new.solve(m)
260
+ result.objective # 4.0
261
+ result.structures["route"].item_at # [3, 1, 4, 2, 0]: the stops in the order visited
262
+ ```
263
+
264
+ A permutation places `n` items in `n` slots, one item per slot, both counted from 0. Here the items are the stops and the slots are the visits, so slot 0 holds the first stop visited:
265
+
266
+ - `route.item_at(k)` is the stop in slot `k`: `route.item_at(0)` is the first stop visited.
267
+ - `route.slot_of(i)` is the slot of stop `i`: when it is visited.
268
+ - `route.precede(a, b)` requires stop `a` to be visited before stop `b`. Every solution meets every such requirement.
269
+ - `start:` gives the order the service's search starts from: `m.permutation("route", 5, start: [4, 3, 2, 1, 0])` starts with stop 0 in slot 4, stop 1 in slot 3, and so on.
270
+
271
+ The result holds each permutation's order both ways round, again counted from 0: `item_at`, the item in each slot, and `slot_of`, the slot of each item. Orders are found by a search, which cannot prove that no better order exists, so a model with a permutation comes back with the status `"heuristic"`.
272
+
273
+ A Ruby array cannot be indexed by a decision the service has not made yet, such as which stop comes first; a lookup table can. `dist[route.item_at(k), route.item_at(k + 1)]` is the length of the leg from the `k`-th stop to the next, whichever stops those turn out to be. A table is a vector, indexed `table[i]`, or a matrix, indexed `table[i, j]`. Indexed by whole numbers only, it gives the entry itself (`dist[0, 1]` is `3.0`). It can also be indexed by an integer variable, to read the entry the service picks:
123
274
 
124
275
  ```ruby
125
- bytes = m.encode # binary protobuf, ready to POST
126
- program = m.to_program # the structured wire IR
127
- Quicopt.decode(bytes) # back to a program
276
+ m = Quicopt::Model.new
277
+ price = m.lookup_table("price", [3.0, 1.0, 4.0, 1.5])
278
+ choice = m.int_var(0, 3, "choice")
279
+ m.minimize(price[choice])
128
280
 
129
- Quicopt::Client.new.solve(bytes) # solve pre-encoded bytes directly
281
+ result = Quicopt::Client.new.solve(m)
282
+ result.solution # {"choice" => 1.0}: option 1 is the cheapest
283
+ result.objective # 1.0
130
284
  ```
131
285
 
132
- The wire schema is `proto/quicopt/modeler/v1/program.proto`, vendored in this
133
- repository; `gen/gen.sh` regenerates the codec from it.
286
+ A value read from a permutation or a table counts as one factor in an expression, like a variable: `2 * dist[i, j]` is linear and `x * dist[i, j]` is quadratic. The whole table is sent with the model, so a very large table makes the request large.
287
+
288
+ ## Minimum loads, fees and groups
289
+
290
+ A delivery is often either nothing or at least a minimum load. `semi: true` says so: `m.int_var(10, 25, "ship", semi: true)` is 0 or a whole number from 10 to 25 (`num_var` for any value in the range). `ship.active` is 1 when such a delivery takes place, so a fee per delivery is one term, and `Quicopt.holds` turns a comparison into 1 or 0, which charges a fee once a total reaches a level. `m.block` names the variables of one subproblem, such as one customer's deliveries; every solution the service returns satisfies the constraints over one block's variables alone. Three warehouses supply three customers:
291
+
292
+ ```ruby
293
+ demand = [40, 30, 20] # units, customers 0 to 2
294
+ price = [[1, 3, 4], [3, 1, 3], [4, 2, 1]] # per unit, price[warehouse][customer]
295
+ opening = [60, 50, 40] # fee of warehouses 0 to 2
296
+
297
+ m = Quicopt::Model.new
298
+ # ship[w][c]: the delivery from warehouse w to customer c, none or 10 to 25 units
299
+ ship = (0...3).map { |w| (0...3).map { |c| m.int_var(10, 25, "ship_#{w}_#{c}", semi: true) } }
300
+ (0...3).each do |c|
301
+ to_customer = (0...3).map { |w| ship[w][c] }
302
+ m.block("customer_#{c}", to_customer)
303
+ m.add(to_customer.sum >= demand[c])
304
+ end
305
+ m.minimize(
306
+ (0...3).sum { |w| (0...3).sum { |c| price[w][c] * ship[w][c] + 5 * ship[w][c].active } } + # 5 per delivery
307
+ (0...3).sum { |w| opening[w] * Quicopt.holds(ship[w].sum >= 1) } # opening fee
308
+ )
309
+
310
+ result = Quicopt::Client.new.solve(m)
311
+ result.objective # 300.0
312
+ result.solution.select { |_, units| units > 0 }.sort.to_h
313
+ # {"ship_0_0" => 25.0, "ship_0_1" => 10.0, "ship_2_0" => 15.0, "ship_2_1" => 20.0, "ship_2_2" => 20.0}
314
+ ```
134
315
 
135
- Note that the generated codec omits zero-valued scalars and does not canonicalize
136
- field order, so two encodings of the same model are *decode-equivalent* rather
137
- than byte-identical. Compare decoded programs, never hex.
316
+ Warehouse 1 stays closed. Warehouse 0 sends its full 25 units to customer 0 and the minimum of 10 to customer 1; warehouse 2 covers the rest. `Quicopt.holds` takes `<=`, `>=`, `<`, `>`, `==` and `!=`, and `tol:` lets a comparison miss by that much and still count: `Quicopt.holds(spend >= 300, tol: 0.01)`. `Quicopt.min` and `Quicopt.max` give the smallest and the largest of several expressions.
317
+
318
+ `m.partition` splits items into groups the way `permutation` orders them. Six players of strengths 7, 5, 4, 3, 2 and 1 form a team of four and a team of two, as evenly matched as possible:
319
+
320
+ ```ruby
321
+ strength = [7, 5, 4, 3, 2, 1] # of players 0 to 5
322
+ m = Quicopt::Model.new
323
+ teams = m.partition("teams", 6, 2) # 6 players into teams 0 and 1
324
+ total = (0...2).map { |t| (0...6).sum { |p| strength[p] * Quicopt.holds(teams.part_of(p) == t) } }
325
+ m.add(teams.size_of(0) == 4) # team 0 has four players
326
+ m.minimize(Quicopt.max(total[0], total[1]))
327
+
328
+ result = Quicopt::Client.new.solve(m)
329
+ result.objective # 11.0
330
+ result.structures["teams"].part_of # [1, 0, 1, 0, 0, 0]: players 0 and 2 form team 1
331
+ result.structures["teams"].size_of # [4, 2]
332
+ ```
333
+
334
+ `teams.part_of(p)` is the team of player `p` and `teams.size_of(t)` the number of players in team `t`, both counted from 0. A model with `active` on a variable declared `semi: true`, and otherwise linear, is solved exactly; one with `Quicopt.holds`, `Quicopt.min`, `Quicopt.max` or a partition is solved by a search, so its status is `"heuristic"`. When every variable of such a model is an integer or a binary variable, each needs finite bounds.
335
+
336
+ ## The encoded model
337
+
338
+ `solve` encodes the model for you, but the encoded form is not hidden. Inspect it,
339
+ store it, or send it by some other route:
340
+
341
+ ```ruby
342
+ bytes = m.encode # the message itself: protobuf bytes, ready to POST
343
+ program = m.to_program # the same content as a structured object you can read
344
+ Quicopt.decode(bytes) # bytes back into that object
345
+
346
+ Quicopt::Client.new.solve(bytes) # solve an already-encoded model
347
+ ```
348
+
349
+ The message format is a [protobuf](https://protobuf.dev) schema,
350
+ `proto/quicopt/modeler/v1/program.proto`, vendored in this repository; `gen/gen.sh`
351
+ regenerates the Ruby classes from it. The Python and Julia clients send that same
352
+ format, which is what makes a model portable between them.
353
+
354
+ One caveat if you ever compare two encoded models: protobuf omits fields that hold
355
+ their default value and does not fix the order of map entries, so two encodings of
356
+ the same model can differ byte for byte while meaning exactly the same thing.
357
+ Compare decoded models, never the raw bytes.
138
358
 
139
359
  ## The client
140
360
 
@@ -162,6 +382,15 @@ baked into the model.
162
382
 
163
383
  **Large models.** `solve(model, gzip: true)` compresses the request body.
164
384
 
385
+ **Time limit.** The service chooses how long a solve may run. `time_limit:` sets that time instead, in seconds. A solver that proves its answer sooner still stops sooner; a search, which cannot prove it is done, runs for all of it:
386
+
387
+ ```ruby
388
+ result = client.solve(m, time_limit: 30)
389
+ result.solver_data["time_budget_s"] # 30.0: the time the service granted
390
+ ```
391
+
392
+ The service grants at most its maximum (120 s on the free tier). Asked for more, it runs for its maximum, and the client warns that the solve got less time than requested. `submit` takes `time_limit:` too, and `job.result` then waits at least that long.
393
+
165
394
  **Asynchronous solving.** Use this for the first call against a server that may
166
395
  still be warming up, where a blocking call could time out:
167
396
 
@@ -193,11 +422,11 @@ Everything this gem raises descends from `Quicopt::Error`.
193
422
  ```sh
194
423
  bundle install
195
424
  bundle exec rake # run the tests
196
- gen/gen.sh # regenerate the codec after a schema change
425
+ gen/gen.sh # regenerate the protobuf classes after a schema change
197
426
  ```
198
427
 
199
428
  The client tests run against a real local HTTP server rather than a stubbed
200
- `Net::HTTP`, so the transport — headers, query encoding, gzip framing, key
429
+ `Net::HTTP`, so the whole request path — headers, query encoding, gzip framing, key
201
430
  capture, the error path — is covered end to end.
202
431
 
203
432
  ## Licence