quicopt 0.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b3149e149b55706102ba346c10b6768e8cfbab47977aaf6ca979cb388c4ee1b9
4
- data.tar.gz: 88307afbd6a77015173c066bf0f1162010f041c72a5666549e9e3be013dda446
3
+ metadata.gz: 16cdf553d639def3a3a5cbbdb7c9e4240118dc619595edeb9dd710cee798362d
4
+ data.tar.gz: 96ce7ae36eb75c68c493a749be503b28240cf843acd5d1196dfaf63410896fcc
5
5
  SHA512:
6
- metadata.gz: 6c4844cbe4e9b8d1577125bc9671ffb0a6a6993d2d18570579d82ef3ca412427bcd854a7e925e75f0ca85473277e895a2c2b9b08487c3cdfe7b7a5af20bb0226
7
- data.tar.gz: d78fc525172e40e19a47b4eec7811b34ba843d9a43a21ecac3609f7a366b4f3aa65cecb660891587fea5a02a69362a48ce6e9f35b356bae1cddcdabcbc0ee12a
6
+ metadata.gz: 6cc023a263fe6c075da89d9ab6dbdbddc3ad647763a9bd6b58c2f4b50aad76356d686d7256a7ff8fb0c04854419e8df9d8c683586b220d333fcd31808706f441
7
+ data.tar.gz: 8a375d5f3f93971f46b9e2a496677f17478b55f7af822a26239a77dd1e5a5a4a90e4a85f19365b614f2f2f1ed9d2f35c5af1f2040a8df52f9d17924e856f3df2
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
+ orderings 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
 
@@ -37,10 +60,12 @@ z = m.num_var(0, 10, "z")
37
60
  m.maximize(3 * x + 2 * y + z)
38
61
  m.add(x + y <= 5)
39
62
 
40
- result = Quicopt::Client.new.solve(m)
63
+ result = Quicopt::Client.new(api_key: "…").solve(m) # the key from your sign-up email
41
64
  puts result.display
42
65
  ```
43
66
 
67
+ The service needs an API key. Register at https://quicopt.com/signup and the key arrives by email. Without a key, the call fails with a message that links to the sign-up page.
68
+
44
69
  `result.display` is rendered by the service and ready to print — the same summary
45
70
  for every kind of model. The structured fields are there too:
46
71
 
@@ -52,7 +77,109 @@ result.solution # {"x" => 4.0, "y" => 1.0, "z" => 10.0}
52
77
  result.solve_time_seconds
53
78
  ```
54
79
 
55
- ## The model
80
+ ## A first realistic example: Sourcing & Procurement
81
+
82
+ 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
83
+
84
+ ```ruby
85
+ parts = %w[resistor_pack terminal_block sensor_module]
86
+ demand = { "resistor_pack" => 200, "terminal_block" => 150, "sensor_module" => 95 }
87
+
88
+ suppliers = %w[Nordwind BitBazaar CircuitCellar]
89
+ price = {
90
+ ["Nordwind", "resistor_pack"] => 1.00, ["BitBazaar", "terminal_block"] => 1.50, ["CircuitCellar", "sensor_module"] => 2.90,
91
+ ["BitBazaar", "resistor_pack"] => 1.15, ["Nordwind", "terminal_block"] => 1.90, ["Nordwind", "sensor_module"] => 3.30,
92
+ ["CircuitCellar", "resistor_pack"] => 1.30, ["CircuitCellar", "terminal_block"] => 2.10, ["BitBazaar", "sensor_module"] => 3.50,
93
+ }
94
+ ```
95
+ So far, so simple. Each supplier also has shipping fees as well as a "free-shipping threshold", which we can write as
96
+ ```ruby
97
+ ship_fee = { "Nordwind" => 40.0, "BitBazaar" => 40.0, "CircuitCellar" => 40.0 }
98
+ free_above = { "Nordwind" => 300.0, "BitBazaar" => 300.0, "CircuitCellar" => 300.0 }
99
+ ```
100
+ It's obvious what to order where, right? Picking the cheapest option for each part, we pay
101
+ ```ruby
102
+ demand["resistor_pack"] * price[["Nordwind", "resistor_pack"]] +
103
+ demand["terminal_block"] * price[["BitBazaar", "terminal_block"]] +
104
+ demand["sensor_module"] * price[["CircuitCellar", "sensor_module"]] +
105
+ ship_fee.values.sum # == 820.5
106
+ ```
107
+ Let's check what a professional optimization solver has to say - we have `Quicopt` at hand:
108
+ ```ruby
109
+ # new model
110
+ m = Quicopt::Model.new
111
+ ```
112
+
113
+ ### Variables
114
+
115
+ ```ruby
116
+ # empty hash literal to hold "quantity of part p ordered from supplier s"
117
+ order_qty = {}
118
+
119
+ # order quantities: integers with lower bounds 0 and no (nil) upper bounds
120
+ suppliers.each { |s| parts.each { |p| order_qty[[s, p]] = m.int_var(0, nil, "x_#{s}_#{p}") } }
121
+
122
+ # binary usage variables: are we using supplier s?
123
+ use = suppliers.to_h { |s| [s, m.bin_var("use_#{s}")] }
124
+
125
+ # binary free-shipping variables: have we ordered enough to qualify?
126
+ free = suppliers.to_h { |s| [s, m.bin_var("free_#{s}")] }
127
+
128
+ # define a helper expression (sum over price times order quantity)
129
+ cart_value = suppliers.to_h { |s| [s, parts.sum { |p| price[[s, p]] * order_qty[[s, p]] }] }
130
+ ```
131
+
132
+ ### Constraints & Objective
133
+
134
+ ```ruby
135
+ # demand satisfaction: a constraint to enforce that the ordered quantity is AT LEAST the demand
136
+ parts.each { |p| m.add(suppliers.sum { |s| order_qty[[s, p]] } >= demand[p]) }
137
+
138
+ # introduce a "large" number to enfore a so-called "big-M" constraint
139
+ big = demand.values.sum * 2
140
+ # => as soon as order_qty is NOT zero, use is forced to one
141
+ suppliers.each { |s| parts.each { |p| m.add(order_qty[[s, p]] <= big * use[s]) } }
142
+
143
+ # free-shipping binaries must be zero when use is zero
144
+ suppliers.each { |s| m.add(free[s] <= use[s]) }
145
+
146
+ # since free occurs with a minus in the objective below,
147
+ # it will be set to one if not forced to zero as long as free_above is not cleared
148
+ suppliers.each { |s| m.add(free_above[s] * free[s] <= cart_value[s]) }
149
+
150
+ # our cost objective function to be minimized
151
+ # the shipping fee is not charged if both use and free are one
152
+ m.minimize(suppliers.sum { |s| cart_value[s] + ship_fee[s] * (use[s] - free[s]) })
153
+ ```
154
+ Now all we have to do is to send this to the Quicopt API service:
155
+ ```ruby
156
+ # get the results from the API service
157
+ result = Quicopt::Client.new(api_key: "…").solve(m)
158
+
159
+ # inspect the result
160
+ puts result.display
161
+ ```
162
+
163
+ ```
164
+ ▄▀▀▀▄ ▄ █
165
+ █ █ █ █ ▄▄ ▄▀▀▀▄ ▄▀▀▀▄ █▀▀▀▄ ▀█▀▀
166
+ █ ▀▄▀ █ █ █ █ ▄ █ █ █▄▄▄▀ █ ▄
167
+ ▀▀ ▀ ▀▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ █ ▀▀
168
+ ├── status: optimal
169
+ ├── feasible: true
170
+ ├── objective: 733.35
171
+ ├── x: free_BitBazaar=1, free_CircuitCellar=1, free_Nordwind=0, use_BitBazaar=1, use_CircuitCellar=1, use_Nordwind=0, … (15 variables)
172
+ └── solve_time: 0.0497 s
173
+ ```
174
+ 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
175
+ ```ruby
176
+ "x_BitBazaar_resistor_pack" => 181.0,
177
+ "x_CircuitCellar_resistor_pack" => 19.0
178
+ ```
179
+ 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).
180
+
181
+
182
+ ## Building a model
56
183
 
57
184
  ### Variables
58
185
 
@@ -68,8 +195,8 @@ it, so naming your variables is worth it. `start:` sets the initial point; it
68
195
  defaults to 0 clamped into the bounds.
69
196
 
70
197
  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.
198
+ rule the Python and Julia clients apply, so the same model means the same thing
199
+ whichever language wrote it.
73
200
 
74
201
  ### Expressions
75
202
 
@@ -83,16 +210,16 @@ z**2 # quadratic
83
210
  x / 2 # division by a constant
84
211
  ```
85
212
 
86
- Expressions are normalised as they are built — a constant plus linear terms plus
213
+ Like terms are collected as you write them — a constant, plus linear terms, plus
87
214
  quadratic terms — so `x * y` and `y * x` are the same term, and `x - x` is zero.
88
215
 
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:
216
+ Only linear and quadratic expressions exist. Anything beyond that raises where you
217
+ wrote it, rather than being silently dropped or approximated:
91
218
 
92
219
  ```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
220
+ x * y * z # Quicopt::UnsupportedExpression — that would be cubic
221
+ x / y # Quicopt::UnsupportedExpression — you can only divide by a number
222
+ x**3 # Quicopt::UnsupportedExpression — exponents are 0, 1 or 2
96
223
  ```
97
224
 
98
225
  ### Constraints
@@ -110,51 +237,136 @@ Two consequences of that, both deliberate:
110
237
  - `x == 5` is a row. Comparing a variable to something that could never be part
111
238
  of an expression (`x == nil`) still answers `false`, and variables remain
112
239
  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`.
240
+ - `!=` is not a constraint Quicopt can express, so it raises rather than quietly
241
+ answering `false`.
114
242
  - A number on the left works (`5 <= x`), because Ruby hands the comparison back
115
243
  to the expression. `5 == x`, however, is Ruby's own `Integer#==` and is just
116
244
  `false` — write `x == 5`.
117
245
 
118
246
  Chained comparisons (`1 <= x <= 5`) are not a thing in Ruby. Add two rows.
119
247
 
120
- ## Working with the wire bytes
248
+ ## Choosing an ordering
121
249
 
122
- The encoded program is not hidden. Inspect it, cache it, or route it yourself:
250
+ Some decisions are orderings: the route a van drives through its stops, or the sequence in which a machine works through its jobs. Declare such an ordering 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:
123
251
 
124
252
  ```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
253
+ position = [0, 3, 1, 4, 2] # km along the road, stops 0 to 4
128
254
 
129
- Quicopt::Client.new.solve(bytes) # solve pre-encoded bytes directly
255
+ m = Quicopt::Model.new
256
+ dist = m.lookup_table("dist", position.map { |a| position.map { |b| (a - b).abs } })
257
+ route = m.permutation("route", 5)
258
+ route.precede(3, 0) # stop 3 is visited before stop 0
259
+ m.minimize((0...4).sum { |k| dist[route.item_at(k), route.item_at(k + 1)] })
260
+
261
+ result = Quicopt::Client.new(api_key: "…").solve(m)
262
+ result.objective # 4.0
263
+ result.structures["route"].item_at # [3, 1, 4, 2, 0]: the stops in the order visited
130
264
  ```
131
265
 
132
- The wire schema is `proto/quicopt/modeler/v1/program.proto`, vendored in this
133
- repository; `gen/gen.sh` regenerates the codec from it.
266
+ A permutation places items in as many 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:
134
267
 
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.
268
+ - `route.item_at(k)` is the stop in slot `k`: `route.item_at(0)` is the first stop visited.
269
+ - `route.slot_of(i)` is the slot of stop `i`: when it is visited.
270
+ - `route.precede(a, b)` requires stop `a` to be visited before stop `b`. Every solution meets every such requirement.
271
+ - `start:` gives the ordering 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.
138
272
 
139
- ## The client
273
+ The result holds each permutation's ordering both ways round, again counted from 0: `item_at`, the item in each slot, and `slot_of`, the slot of each item. Orderings are found by a search, which cannot prove that no better ordering exists, so a model with a permutation comes back with the status `"heuristic"`.
274
+
275
+ 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:
276
+
277
+ ```ruby
278
+ m = Quicopt::Model.new
279
+ price = m.lookup_table("price", [3.0, 1.0, 4.0, 1.5])
280
+ choice = m.int_var(0, 3, "choice")
281
+ m.minimize(price[choice])
282
+
283
+ result = Quicopt::Client.new(api_key: "…").solve(m)
284
+ result.solution # {"choice" => 1.0}: option 1 is the cheapest
285
+ result.objective # 1.0
286
+ ```
287
+
288
+ 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.
289
+
290
+ ## Minimum loads, fees and groups
291
+
292
+ 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:
293
+
294
+ ```ruby
295
+ demand = [40, 30, 20] # units, customers 0 to 2
296
+ price = [[1, 3, 4], [3, 1, 3], [4, 2, 1]] # per unit, price[warehouse][customer]
297
+ opening = [60, 50, 40] # fee of warehouses 0 to 2
298
+
299
+ m = Quicopt::Model.new
300
+ # ship[w][c]: the delivery from warehouse w to customer c, none or 10 to 25 units
301
+ ship = (0...3).map { |w| (0...3).map { |c| m.int_var(10, 25, "ship_#{w}_#{c}", semi: true) } }
302
+ (0...3).each do |c|
303
+ to_customer = (0...3).map { |w| ship[w][c] }
304
+ m.block("customer_#{c}", to_customer)
305
+ m.add(to_customer.sum >= demand[c])
306
+ end
307
+ m.minimize(
308
+ (0...3).sum { |w| (0...3).sum { |c| price[w][c] * ship[w][c] + 5 * ship[w][c].active } } + # 5 per delivery
309
+ (0...3).sum { |w| opening[w] * Quicopt.holds(ship[w].sum >= 1) } # opening fee
310
+ )
311
+
312
+ result = Quicopt::Client.new(api_key: "…").solve(m)
313
+ result.objective # 300.0
314
+ result.solution.select { |_, units| units > 0 }.sort.to_h
315
+ # {"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}
316
+ ```
317
+
318
+ 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.
319
+
320
+ `m.partition` makes a split of items into groups a decision, the way `permutation` makes an ordering one. 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:
321
+
322
+ ```ruby
323
+ strength = [7, 5, 4, 3, 2, 1] # of players 0 to 5
324
+ m = Quicopt::Model.new
325
+ teams = m.partition("teams", 6, 2) # 6 players into teams 0 and 1
326
+ total = (0...2).map { |t| (0...6).sum { |p| strength[p] * Quicopt.holds(teams.part_of(p) == t) } }
327
+ m.add(teams.size_of(0) == 4) # team 0 has four players
328
+ m.minimize(Quicopt.max(total[0], total[1]))
329
+
330
+ result = Quicopt::Client.new(api_key: "…").solve(m)
331
+ result.objective # 11.0
332
+ result.structures["teams"].part_of # [1, 0, 1, 0, 0, 0]: players 0 and 2 form team 1
333
+ result.structures["teams"].size_of # [4, 2]
334
+ ```
335
+
336
+ `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.
337
+
338
+ ## The encoded model
339
+
340
+ `solve` encodes the model for you, but the encoded form is not hidden. Inspect it,
341
+ store it, or send it by some other route:
140
342
 
141
343
  ```ruby
142
- client = Quicopt::Client.new # the public free tier
143
- client = Quicopt::Client.new("http://localhost:8137")
144
- client = Quicopt::Client.new(url, "my-api-key", timeout: 120)
344
+ bytes = m.encode # the message itself: protobuf bytes, ready to POST
345
+ program = m.to_program # the same content as a structured object you can read
346
+ Quicopt.decode(bytes) # bytes back into that object
347
+
348
+ Quicopt::Client.new(api_key: "…").solve(bytes) # solve an already-encoded model
145
349
  ```
146
350
 
147
- **Keys.** The first call without a key mints one, cached at
148
- `$XDG_CACHE_HOME/quicopt/free_key` (`~/.cache/…` by default) and replayed as a
149
- bearer token on every later call — including from later runs, so you keep one key
150
- without doing anything. A key you passed in is used as-is, never cached and never
151
- overwritten; `Quicopt::Client.new(url, cache: false)` keeps the minted key in
152
- memory only.
351
+ The message format is a [protobuf](https://protobuf.dev) schema,
352
+ `proto/quicopt/modeler/v1/program.proto`, vendored in this repository; `gen/gen.sh`
353
+ regenerates the Ruby classes from it. The Python and Julia clients send that same
354
+ format, which is what makes a model portable between them.
355
+
356
+ One caveat if you ever compare two encoded models: protobuf omits fields that hold
357
+ their default value and does not fix the order of map entries, so two encodings of
358
+ the same model can differ byte for byte while meaning exactly the same thing.
359
+ Compare decoded models, never the raw bytes.
153
360
 
154
- Where the home directory does not survive the run (CI, containers), the cache is
155
- wiped between sessions and each run mints a new key. Set `QUICOPT_KEY_PATH` to a
156
- durable location — or `Client.new(url, key_path: …)` — to keep one key across
157
- sessions.
361
+ ## The client
362
+
363
+ ```ruby
364
+ client = Quicopt::Client.new(api_key: "…") # the Quicopt service
365
+ client = Quicopt::Client.new("http://localhost:8137", api_key: "…")
366
+ client = Quicopt::Client.new(api_key: "…", timeout: 120)
367
+ ```
368
+
369
+ **Keys.** Register at https://quicopt.com/signup and the key arrives by email. Pass it as `api_key:`. To keep the key out of your code, set the environment variable `QUICOPT_API_KEY` instead and create the client as `Quicopt::Client.new`.
158
370
 
159
371
  **Per-project tagging.** `solve(model, project: "atlas")` tags the call so usage on
160
372
  one key can be attributed per project. It rides the query string and is never
@@ -162,6 +374,15 @@ baked into the model.
162
374
 
163
375
  **Large models.** `solve(model, gzip: true)` compresses the request body.
164
376
 
377
+ **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, may stop sooner too, once it stops finding better answers:
378
+
379
+ ```ruby
380
+ result = client.solve(m, time_limit: 30)
381
+ result.solver_data["time_budget_s"] # 30.0: the time the service granted
382
+ ```
383
+
384
+ 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.
385
+
165
386
  **Asynchronous solving.** Use this for the first call against a server that may
166
387
  still be warming up, where a blocking call could time out:
167
388
 
@@ -193,11 +414,11 @@ Everything this gem raises descends from `Quicopt::Error`.
193
414
  ```sh
194
415
  bundle install
195
416
  bundle exec rake # run the tests
196
- gen/gen.sh # regenerate the codec after a schema change
417
+ gen/gen.sh # regenerate the protobuf classes after a schema change
197
418
  ```
198
419
 
199
420
  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
421
+ `Net::HTTP`, so the whole request path — headers, query encoding, gzip framing, key
201
422
  capture, the error path — is covered end to end.
202
423
 
203
424
  ## Licence