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 +4 -4
- data/.yardopts +2 -1
- data/README.md +269 -48
- data/lib/quicopt/client.rb +219 -91
- data/lib/quicopt/indicators.rb +236 -0
- data/lib/quicopt/model.rb +463 -67
- data/lib/quicopt/modeler/v1/program_pb.rb +10 -1
- data/lib/quicopt/structured.rb +438 -0
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +173 -37
- data/lib/quicopt.rb +8 -7
- 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: 16cdf553d639def3a3a5cbbdb7c9e4240118dc619595edeb9dd710cee798362d
|
|
4
|
+
data.tar.gz: 96ce7ae36eb75c68c493a749be503b28240cf843acd5d1196dfaf63410896fcc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6cc023a263fe6c075da89d9ab6dbdbddc3ad647763a9bd6b58c2f4b50aad76356d686d7256a7ff8fb0c04854419e8df9d8c683586b220d333fcd31808706f441
|
|
7
|
+
data.tar.gz: 8a375d5f3f93971f46b9e2a496677f17478b55f7af822a26239a77dd1e5a5a4a90e4a85f19365b614f2f2f1ed9d2f35c5af1f2040a8df52f9d17924e856f3df2
|
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
|
+
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
|
|
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
|
|
|
@@ -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
|
-
##
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
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
|
|
94
|
-
x / y # Quicopt::UnsupportedExpression
|
|
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
|
-
- `!=`
|
|
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
|
-
##
|
|
248
|
+
## Choosing an ordering
|
|
121
249
|
|
|
122
|
-
|
|
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
|
-
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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
|
|
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
|