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 +4 -4
- data/.yardopts +2 -1
- data/README.md +262 -33
- data/lib/quicopt/client.rb +156 -49
- data/lib/quicopt/indicators.rb +235 -0
- data/lib/quicopt/model.rb +442 -57
- data/lib/quicopt/modeler/v1/program_pb.rb +10 -1
- data/lib/quicopt/structured.rb +437 -0
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +171 -36
- data/lib/quicopt.rb +7 -6
- data/proto/quicopt/modeler/v1/program.proto +160 -6
- metadata +12 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 370fa0c8906d43f509f9262406805bdc0e1357248e8271125b6b76a096a2fd6f
|
|
4
|
+
data.tar.gz: 0df9e60f7456ae1b0ecae3a9d3ffb668490c684a095e71f4ebfb614f522c15f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4d0c6ef70c1f502da08557c366f31695293a52886b72bc6f214d89c902eedb21862dbe3a14223184adaa3eb64873dccf52a54609ce836e4363f92f77ef9344b
|
|
7
|
+
data.tar.gz: e1d1ac282698f36bbe968c9099029db5f3e12bbbe6fcb7fded6dd83dfe1e49b66bdba22e267ca64845deb12cfd6d93adc24b434066470417dfbad162deeeb40f
|
data/.yardopts
CHANGED
data/README.md
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
1
|
# quicopt
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
[](https://rubygems.org/gems/quicopt)
|
|
4
|
+
[](https://rubygems.org/gems/quicopt)
|
|
5
|
+
[](https://quicopt.github.io/quicopt-ruby/)
|
|
6
|
+
[](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
|
|
25
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
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
|
|
94
|
-
x / y # Quicopt::UnsupportedExpression
|
|
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
|
-
- `!=`
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
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
|
-
|
|
133
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
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
|
|
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
|