quicopt 0.3.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/README.md +21 -29
- data/lib/quicopt/client.rb +68 -47
- data/lib/quicopt/indicators.rb +9 -8
- data/lib/quicopt/model.rb +70 -59
- data/lib/quicopt/structured.rb +40 -39
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +7 -6
- data/lib/quicopt.rb +1 -1
- metadata +1 -1
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/README.md
CHANGED
|
@@ -26,7 +26,7 @@ There is no solver on your machine. The service does the solving.
|
|
|
26
26
|
variables, constraints, objective.
|
|
27
27
|
- [`Quicopt::Permutation`](https://quicopt.github.io/quicopt-ruby/Quicopt/Permutation.html)
|
|
28
28
|
and [`Quicopt::LookupTable`](https://quicopt.github.io/quicopt-ruby/Quicopt/LookupTable.html) —
|
|
29
|
-
|
|
29
|
+
orderings as decisions, and data read along them.
|
|
30
30
|
- [`Quicopt::Client`](https://quicopt.github.io/quicopt-ruby/Quicopt/Client.html) —
|
|
31
31
|
sending a model, API keys, long-running jobs, errors.
|
|
32
32
|
- [`Quicopt::Result`](https://quicopt.github.io/quicopt-ruby/Quicopt/Result.html) —
|
|
@@ -60,10 +60,12 @@ z = m.num_var(0, 10, "z")
|
|
|
60
60
|
m.maximize(3 * x + 2 * y + z)
|
|
61
61
|
m.add(x + y <= 5)
|
|
62
62
|
|
|
63
|
-
result = Quicopt::Client.new.solve(m)
|
|
63
|
+
result = Quicopt::Client.new(api_key: "…").solve(m) # the key from your sign-up email
|
|
64
64
|
puts result.display
|
|
65
65
|
```
|
|
66
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
|
+
|
|
67
69
|
`result.display` is rendered by the service and ready to print — the same summary
|
|
68
70
|
for every kind of model. The structured fields are there too:
|
|
69
71
|
|
|
@@ -152,7 +154,7 @@ m.minimize(suppliers.sum { |s| cart_value[s] + ship_fee[s] * (use[s] - free[s])
|
|
|
152
154
|
Now all we have to do is to send this to the Quicopt API service:
|
|
153
155
|
```ruby
|
|
154
156
|
# get the results from the API service
|
|
155
|
-
result = Quicopt::Client.new.solve(m)
|
|
157
|
+
result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
156
158
|
|
|
157
159
|
# inspect the result
|
|
158
160
|
puts result.display
|
|
@@ -243,9 +245,9 @@ Two consequences of that, both deliberate:
|
|
|
243
245
|
|
|
244
246
|
Chained comparisons (`1 <= x <= 5`) are not a thing in Ruby. Add two rows.
|
|
245
247
|
|
|
246
|
-
## Choosing an
|
|
248
|
+
## Choosing an ordering
|
|
247
249
|
|
|
248
|
-
Some decisions are
|
|
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:
|
|
249
251
|
|
|
250
252
|
```ruby
|
|
251
253
|
position = [0, 3, 1, 4, 2] # km along the road, stops 0 to 4
|
|
@@ -256,19 +258,19 @@ route = m.permutation("route", 5)
|
|
|
256
258
|
route.precede(3, 0) # stop 3 is visited before stop 0
|
|
257
259
|
m.minimize((0...4).sum { |k| dist[route.item_at(k), route.item_at(k + 1)] })
|
|
258
260
|
|
|
259
|
-
result = Quicopt::Client.new.solve(m)
|
|
261
|
+
result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
260
262
|
result.objective # 4.0
|
|
261
263
|
result.structures["route"].item_at # [3, 1, 4, 2, 0]: the stops in the order visited
|
|
262
264
|
```
|
|
263
265
|
|
|
264
|
-
A permutation places
|
|
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:
|
|
265
267
|
|
|
266
268
|
- `route.item_at(k)` is the stop in slot `k`: `route.item_at(0)` is the first stop visited.
|
|
267
269
|
- `route.slot_of(i)` is the slot of stop `i`: when it is visited.
|
|
268
270
|
- `route.precede(a, b)` requires stop `a` to be visited before stop `b`. Every solution meets every such requirement.
|
|
269
|
-
- `start:` gives the
|
|
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.
|
|
270
272
|
|
|
271
|
-
The result holds each permutation's
|
|
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"`.
|
|
272
274
|
|
|
273
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:
|
|
274
276
|
|
|
@@ -278,7 +280,7 @@ price = m.lookup_table("price", [3.0, 1.0, 4.0, 1.5])
|
|
|
278
280
|
choice = m.int_var(0, 3, "choice")
|
|
279
281
|
m.minimize(price[choice])
|
|
280
282
|
|
|
281
|
-
result = Quicopt::Client.new.solve(m)
|
|
283
|
+
result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
282
284
|
result.solution # {"choice" => 1.0}: option 1 is the cheapest
|
|
283
285
|
result.objective # 1.0
|
|
284
286
|
```
|
|
@@ -307,7 +309,7 @@ m.minimize(
|
|
|
307
309
|
(0...3).sum { |w| opening[w] * Quicopt.holds(ship[w].sum >= 1) } # opening fee
|
|
308
310
|
)
|
|
309
311
|
|
|
310
|
-
result = Quicopt::Client.new.solve(m)
|
|
312
|
+
result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
311
313
|
result.objective # 300.0
|
|
312
314
|
result.solution.select { |_, units| units > 0 }.sort.to_h
|
|
313
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}
|
|
@@ -315,7 +317,7 @@ result.solution.select { |_, units| units > 0 }.sort.to_h
|
|
|
315
317
|
|
|
316
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.
|
|
317
319
|
|
|
318
|
-
`m.partition`
|
|
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:
|
|
319
321
|
|
|
320
322
|
```ruby
|
|
321
323
|
strength = [7, 5, 4, 3, 2, 1] # of players 0 to 5
|
|
@@ -325,7 +327,7 @@ total = (0...2).map { |t| (0...6).sum { |p| strength[p] * Quicopt.holds(teams.pa
|
|
|
325
327
|
m.add(teams.size_of(0) == 4) # team 0 has four players
|
|
326
328
|
m.minimize(Quicopt.max(total[0], total[1]))
|
|
327
329
|
|
|
328
|
-
result = Quicopt::Client.new.solve(m)
|
|
330
|
+
result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
329
331
|
result.objective # 11.0
|
|
330
332
|
result.structures["teams"].part_of # [1, 0, 1, 0, 0, 0]: players 0 and 2 form team 1
|
|
331
333
|
result.structures["teams"].size_of # [4, 2]
|
|
@@ -343,7 +345,7 @@ bytes = m.encode # the message itself: protobuf bytes, ready to POST
|
|
|
343
345
|
program = m.to_program # the same content as a structured object you can read
|
|
344
346
|
Quicopt.decode(bytes) # bytes back into that object
|
|
345
347
|
|
|
346
|
-
Quicopt::Client.new.solve(bytes) # solve an already-encoded model
|
|
348
|
+
Quicopt::Client.new(api_key: "…").solve(bytes) # solve an already-encoded model
|
|
347
349
|
```
|
|
348
350
|
|
|
349
351
|
The message format is a [protobuf](https://protobuf.dev) schema,
|
|
@@ -359,22 +361,12 @@ Compare decoded models, never the raw bytes.
|
|
|
359
361
|
## The client
|
|
360
362
|
|
|
361
363
|
```ruby
|
|
362
|
-
client = Quicopt::Client.new
|
|
363
|
-
client = Quicopt::Client.new("http://localhost:8137")
|
|
364
|
-
client = Quicopt::Client.new(
|
|
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)
|
|
365
367
|
```
|
|
366
368
|
|
|
367
|
-
**Keys.**
|
|
368
|
-
`$XDG_CACHE_HOME/quicopt/free_key` (`~/.cache/…` by default) and replayed as a
|
|
369
|
-
bearer token on every later call — including from later runs, so you keep one key
|
|
370
|
-
without doing anything. A key you passed in is used as-is, never cached and never
|
|
371
|
-
overwritten; `Quicopt::Client.new(url, cache: false)` keeps the minted key in
|
|
372
|
-
memory only.
|
|
373
|
-
|
|
374
|
-
Where the home directory does not survive the run (CI, containers), the cache is
|
|
375
|
-
wiped between sessions and each run mints a new key. Set `QUICOPT_KEY_PATH` to a
|
|
376
|
-
durable location — or `Client.new(url, key_path: …)` — to keep one key across
|
|
377
|
-
sessions.
|
|
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`.
|
|
378
370
|
|
|
379
371
|
**Per-project tagging.** `solve(model, project: "atlas")` tags the call so usage on
|
|
380
372
|
one key can be attributed per project. It rides the query string and is never
|
|
@@ -382,7 +374,7 @@ baked into the model.
|
|
|
382
374
|
|
|
383
375
|
**Large models.** `solve(model, gzip: true)` compresses the request body.
|
|
384
376
|
|
|
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,
|
|
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:
|
|
386
378
|
|
|
387
379
|
```ruby
|
|
388
380
|
result = client.solve(m, time_limit: 30)
|
data/lib/quicopt/client.rb
CHANGED
|
@@ -15,11 +15,9 @@ require "zlib"
|
|
|
15
15
|
# out over stdlib +net/http+, so the gem's only runtime dependency stays
|
|
16
16
|
# +google-protobuf+. The request body is the encoded model; the response is the
|
|
17
17
|
# service's result JSON (+status+ / +objective+ / +solution+ / a ready-to-print
|
|
18
|
-
# +display+ / …).
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
# every later call — including from a later *process*, so one caller keeps one key
|
|
22
|
-
# rather than minting a fresh one per run.
|
|
18
|
+
# +display+ / …). Every request carries your API key as
|
|
19
|
+
# <tt>Authorization: Bearer</tt>. Register at https://quicopt.com/signup to
|
|
20
|
+
# receive one by email.
|
|
23
21
|
#
|
|
24
22
|
# Two entry points mirror the two service endpoints:
|
|
25
23
|
#
|
|
@@ -35,10 +33,9 @@ require "zlib"
|
|
|
35
33
|
# warns when it granted less, and waits on the socket (or polls) long enough for
|
|
36
34
|
# the time it asked for.
|
|
37
35
|
module Quicopt
|
|
38
|
-
# The
|
|
39
|
-
#
|
|
40
|
-
|
|
41
|
-
DEFAULT_BASE_URL = "https://try.quicoptapi.pgi.fz-juelich.de"
|
|
36
|
+
# The Quicopt service a +Client+ targets when no +base_url+ is given. The
|
|
37
|
+
# Python, Julia and R clients default to the same address.
|
|
38
|
+
DEFAULT_BASE_URL = "https://quicoptapi.pgi.fz-juelich.de"
|
|
42
39
|
|
|
43
40
|
# The modelling library a model was written in, sent as the +source_language+
|
|
44
41
|
# call tag. It names the *library*, not the programming language — the service
|
|
@@ -124,8 +121,9 @@ module Quicopt
|
|
|
124
121
|
self
|
|
125
122
|
end
|
|
126
123
|
|
|
127
|
-
# The kind of model the service recognised
|
|
128
|
-
# if it did not report
|
|
124
|
+
# The kind of model the service recognised
|
|
125
|
+
# (<tt>LP</tt>/<tt>MILP</tt>/<tt>QUBO</tt>/…), or +nil+ if it did not report
|
|
126
|
+
# one.
|
|
129
127
|
#
|
|
130
128
|
# @return [String, nil]
|
|
131
129
|
def model_class
|
|
@@ -135,7 +133,7 @@ module Quicopt
|
|
|
135
133
|
|
|
136
134
|
# A non-2xx service response.
|
|
137
135
|
#
|
|
138
|
-
# +reason+ is the service's stable snake_case code (
|
|
136
|
+
# +reason+ is the service's stable snake_case code (<tt>size_exceeded</tt>,
|
|
139
137
|
# +unsupported_model+, +quota_exhausted+, +not_done+, +too_many_inflight+,
|
|
140
138
|
# +queue_full+, +parse_error+, …) — match on it, not on the message. +display+
|
|
141
139
|
# is the framed text to print; +status_code+ the HTTP status.
|
|
@@ -158,23 +156,27 @@ module Quicopt
|
|
|
158
156
|
end
|
|
159
157
|
end
|
|
160
158
|
|
|
161
|
-
# Environment variable
|
|
162
|
-
#
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
# Environment variable a +Client+ reads its API key from when none is passed.
|
|
160
|
+
# It keeps the key out of your code.
|
|
161
|
+
API_KEY_ENV = "QUICOPT_API_KEY"
|
|
162
|
+
|
|
163
|
+
# Environment variable overriding where the client saves a key the server
|
|
164
|
+
# issued itself. A server that gives a caller without a key a new one on the
|
|
165
|
+
# first call has the client save that key and reuse it in later runs. Point the
|
|
166
|
+
# variable at durable storage where the home directory does not survive the
|
|
167
|
+
# run (CI, containers).
|
|
165
168
|
KEY_PATH_ENV = "QUICOPT_KEY_PATH"
|
|
166
169
|
|
|
167
|
-
# A connection to a Quicopt service at +base_url
|
|
168
|
-
#
|
|
170
|
+
# A connection to a Quicopt service at +base_url+, by default
|
|
171
|
+
# <tt>DEFAULT_BASE_URL</tt>.
|
|
169
172
|
#
|
|
170
|
-
# The client holds
|
|
171
|
-
#
|
|
172
|
-
#
|
|
173
|
+
# The client holds your API key: the one passed as +api_key:+, else the one in
|
|
174
|
+
# the environment variable <tt>QUICOPT_API_KEY</tt>. Register at
|
|
175
|
+
# https://quicopt.com/signup to receive a key by email.
|
|
173
176
|
#
|
|
174
|
-
# client = Quicopt::Client.new
|
|
177
|
+
# client = Quicopt::Client.new(api_key: "…") # the key from your sign-up email
|
|
175
178
|
# result = client.solve(model)
|
|
176
179
|
# puts result.display
|
|
177
|
-
# client.api_key # => the key the first call minted (and cached)
|
|
178
180
|
class Client
|
|
179
181
|
# The HTTP methods this client issues, and the request class for each.
|
|
180
182
|
REQUESTS = {
|
|
@@ -188,31 +190,38 @@ module Quicopt
|
|
|
188
190
|
|
|
189
191
|
# @return [String] the service base URL, without a trailing slash
|
|
190
192
|
attr_reader :base_url
|
|
191
|
-
# @return [String, nil] the API key in use
|
|
193
|
+
# @return [String, nil] the API key in use
|
|
192
194
|
attr_accessor :api_key
|
|
193
195
|
# @return [Float] the per-request socket timeout, in seconds
|
|
194
196
|
attr_accessor :timeout
|
|
195
|
-
# @return [Pathname] where the
|
|
197
|
+
# @return [Pathname] where a key the server issued is saved
|
|
196
198
|
attr_reader :key_path
|
|
197
199
|
|
|
198
200
|
# Bind a client to a service endpoint.
|
|
199
201
|
#
|
|
200
202
|
# @param base_url [String] the service base URL; a trailing slash is stripped
|
|
201
|
-
# @param
|
|
202
|
-
#
|
|
203
|
-
#
|
|
204
|
-
#
|
|
203
|
+
# @param key [String, nil] your API key, given as the second argument; the
|
|
204
|
+
# same as +api_key:+
|
|
205
|
+
# @param api_key [String, nil] your API key. When +nil+, the client reads the
|
|
206
|
+
# key from the environment variable <tt>QUICOPT_API_KEY</tt>, and failing
|
|
207
|
+
# that uses a key saved by an earlier run (see +key_path:+). A key passed
|
|
208
|
+
# here or read from the environment is used as-is and never saved.
|
|
205
209
|
# @param timeout [Numeric] the per-request socket timeout, in seconds
|
|
206
|
-
# @param key_path [String, Pathname, nil] where to
|
|
207
|
-
# defaults to
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
|
|
211
|
-
|
|
210
|
+
# @param key_path [String, Pathname, nil] where to save a key the server
|
|
211
|
+
# issued itself, on a first call without a key; defaults to
|
|
212
|
+
# +Client.default_key_path+
|
|
213
|
+
# @param cache [Boolean] +false+ keeps an issued key in memory only, neither
|
|
214
|
+
# reading nor writing the file
|
|
215
|
+
def initialize(base_url = DEFAULT_BASE_URL, key = nil, api_key: key,
|
|
216
|
+
timeout: 60.0, key_path: nil, cache: true)
|
|
212
217
|
@base_url = base_url.to_s.sub(%r{/+\z}, "")
|
|
213
218
|
@timeout = timeout
|
|
214
219
|
@key_path = Pathname(key_path || Client.default_key_path).expand_path
|
|
215
220
|
@cache = cache
|
|
221
|
+
api_key = Client.env_key if api_key.nil?
|
|
222
|
+
# A key the caller holds — passed, or set in the environment — is never
|
|
223
|
+
# written to the cache, so it cannot clobber the issued key of whoever runs
|
|
224
|
+
# the code, and is never discarded on a 401.
|
|
216
225
|
@explicit = !api_key.nil?
|
|
217
226
|
@api_key = @explicit ? api_key : (cache ? Client.read_key(@key_path) : nil)
|
|
218
227
|
# Only a key that came off disk may be discarded and re-minted on a 401 —
|
|
@@ -230,10 +239,11 @@ module Quicopt
|
|
|
230
239
|
# model.
|
|
231
240
|
# @param time_limit [Numeric, nil] how many seconds the solve may run, in
|
|
232
241
|
# place of the time the service would choose. A solver that proves its
|
|
233
|
-
# answer sooner stops sooner; a search, which cannot prove it is done,
|
|
234
|
-
#
|
|
235
|
-
# tier), and a warning says so when
|
|
236
|
-
# the answer at least this long plus
|
|
242
|
+
# answer sooner stops sooner; a search, which cannot prove it is done, may
|
|
243
|
+
# stop sooner too, once it stops finding better answers. The service grants
|
|
244
|
+
# at most its maximum (120 s on the free tier), and a warning says so when
|
|
245
|
+
# it granted less. The client waits for the answer at least this long plus
|
|
246
|
+
# a minute.
|
|
237
247
|
# @param config [Hash, nil] optional service parameters, sent as the query
|
|
238
248
|
# string
|
|
239
249
|
# @param gzip [Boolean] gzip-compress the request body
|
|
@@ -361,11 +371,12 @@ module Quicopt
|
|
|
361
371
|
|
|
362
372
|
# Render a config mapping as a URL query-string suffix.
|
|
363
373
|
#
|
|
364
|
-
# Escapes per RFC 3986, so a space becomes +%20+ and never
|
|
365
|
-
# the service's query parser round-trips unambiguously, and the
|
|
366
|
-
# other clients send. Ruby's stock helpers
|
|
367
|
-
# +CGI.escape+,
|
|
368
|
-
#
|
|
374
|
+
# Escapes per RFC 3986, so a space becomes +%20+ and never <tt>'+'</tt> —
|
|
375
|
+
# the form the service's query parser round-trips unambiguously, and the
|
|
376
|
+
# form the other clients send. Ruby's stock helpers
|
|
377
|
+
# (<tt>URI.encode_www_form</tt>, +CGI.escape+,
|
|
378
|
+
# +URI.encode_www_form_component+) all emit <tt>'+'</tt> here, so none of
|
|
379
|
+
# them can be used.
|
|
369
380
|
#
|
|
370
381
|
# @param config [Hash, nil]
|
|
371
382
|
# @return [String] +"?k=v&…"+ if +config+ is non-empty, else +""+
|
|
@@ -413,6 +424,15 @@ module Quicopt
|
|
|
413
424
|
{ "error" => text }
|
|
414
425
|
end
|
|
415
426
|
|
|
427
|
+
# The API key set in the environment variable +API_KEY_ENV+, stripped of
|
|
428
|
+
# surrounding whitespace; +nil+ when the variable is unset or blank.
|
|
429
|
+
#
|
|
430
|
+
# @return [String, nil]
|
|
431
|
+
def self.env_key
|
|
432
|
+
key = ENV.fetch(API_KEY_ENV, "").strip
|
|
433
|
+
key.empty? ? nil : key
|
|
434
|
+
end
|
|
435
|
+
|
|
416
436
|
# The default location of the cached free key:
|
|
417
437
|
# +$XDG_CACHE_HOME/quicopt/free_key+, falling back to +~/.cache+ when the
|
|
418
438
|
# variable is unset, unless +KEY_PATH_ENV+ overrides the whole path.
|
|
@@ -558,8 +578,9 @@ module Quicopt
|
|
|
558
578
|
|
|
559
579
|
# Fetch the job's metadata and framed log tail.
|
|
560
580
|
#
|
|
561
|
-
# @return [Hash] the job state
|
|
562
|
-
#
|
|
581
|
+
# @return [Hash] the job state
|
|
582
|
+
# (<tt>queued</tt>/<tt>running</tt>/<tt>done</tt>/<tt>failed</tt>) and
|
|
583
|
+
# its log tail, as returned by the service
|
|
563
584
|
def status
|
|
564
585
|
@client.request("GET", "/v1/jobs/#{@job_id}")
|
|
565
586
|
end
|
|
@@ -573,7 +594,7 @@ module Quicopt
|
|
|
573
594
|
# @param poll [Numeric] delay between polls, in seconds
|
|
574
595
|
# @return [Result]
|
|
575
596
|
# @raise [QuicoptError] if +wait+ is false and the job is not yet done, on any
|
|
576
|
-
# non
|
|
597
|
+
# non-<tt>not_done</tt> error, or once +timeout+ elapses
|
|
577
598
|
def result(wait: true, timeout: nil, poll: 0.5)
|
|
578
599
|
timeout ||= @time_limit.nil? ? 120.0 : [120.0, @time_limit + WAIT_MARGIN].max
|
|
579
600
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
data/lib/quicopt/indicators.rb
CHANGED
|
@@ -37,8 +37,8 @@ module Quicopt
|
|
|
37
37
|
# @param comparison [Constraint] built with +<=+, +>=+, +<+, +>+, +==+ or
|
|
38
38
|
# +!=+ between expressions, such as +shipped >= 60+
|
|
39
39
|
# @param tol [Numeric] how far a comparison may miss and still count as
|
|
40
|
-
# holding: +a >= b+ holds from +a = b − tol+ on, +a > b+ from
|
|
41
|
-
# Non-negative.
|
|
40
|
+
# holding: +a >= b+ holds from +a = b − tol+ on, +a > b+ from
|
|
41
|
+
# <tt>a > b + tol</tt>. Non-negative.
|
|
42
42
|
# @return [Expression] a 0/1 expression; a number when the comparison
|
|
43
43
|
# involves no decision
|
|
44
44
|
# @raise [TypeError] if +comparison+ is not a comparison — often one Ruby
|
|
@@ -55,8 +55,8 @@ module Quicopt
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# The smallest of the given expressions, as an expression the service
|
|
58
|
-
# evaluates.
|
|
59
|
-
# once, however many of +a+ and +b+ are in use.
|
|
58
|
+
# evaluates. <tt>fee * Quicopt.min(1, a.active + b.active)</tt> is a fee
|
|
59
|
+
# that is due once, however many of +a+ and +b+ are in use.
|
|
60
60
|
#
|
|
61
61
|
# @param args [Array<Expression, Variable, Numeric>] at least two
|
|
62
62
|
# @return [Expression] a number when every argument is one
|
|
@@ -101,7 +101,7 @@ module Quicopt
|
|
|
101
101
|
end
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
# Whether a comparison between two numbers (
|
|
104
|
+
# Whether a comparison between two numbers (<tt>value = lhs − rhs</tt>) holds.
|
|
105
105
|
def truth(value, sense, tol)
|
|
106
106
|
case sense
|
|
107
107
|
when :le then -value + tol >= 0
|
|
@@ -172,8 +172,8 @@ module Quicopt
|
|
|
172
172
|
@argument.variables
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
-
# @return [String] the term as sent, e.g.
|
|
176
|
-
# a bare variable reads +x.active+
|
|
175
|
+
# @return [String] the term as sent, e.g. <tt>step(x + y - 1)</tt>; the
|
|
176
|
+
# indicator of a bare variable reads +x.active+
|
|
177
177
|
def to_s
|
|
178
178
|
bare = @argument.constant.zero? && @argument.quadratic.empty? && @argument.linear.size == 1 &&
|
|
179
179
|
@argument.linear.first[0].is_a?(Variable) && @argument.linear.first[1] == 1.0
|
|
@@ -227,7 +227,8 @@ module Quicopt
|
|
|
227
227
|
@args.flat_map(&:variables).uniq
|
|
228
228
|
end
|
|
229
229
|
|
|
230
|
-
# @return [String] the term as written, e.g.
|
|
230
|
+
# @return [String] the term as written, e.g.
|
|
231
|
+
# <tt>min(1, a.active + b.active)</tt>
|
|
231
232
|
def to_s
|
|
232
233
|
"#{@op}(#{@args.join(', ')})"
|
|
233
234
|
end
|
data/lib/quicopt/model.rb
CHANGED
|
@@ -47,7 +47,7 @@ module Quicopt
|
|
|
47
47
|
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# One term, dropping a unit coefficient (
|
|
50
|
+
# One term, dropping a unit coefficient (<tt>x</tt>, <tt>-x</tt>, <tt>3*x</tt>).
|
|
51
51
|
def term(coefficient, factors)
|
|
52
52
|
return factors if coefficient == 1.0
|
|
53
53
|
return "-#{factors}" if coefficient == -1.0
|
|
@@ -63,7 +63,7 @@ module Quicopt
|
|
|
63
63
|
# what lets +3 * x+ parse: Ruby hands the multiplication back to us rather than
|
|
64
64
|
# failing in +Integer#*+.
|
|
65
65
|
module Operators
|
|
66
|
-
# @return [Expression]
|
|
66
|
+
# @return [Expression] <tt>self + other</tt>
|
|
67
67
|
def +(other)
|
|
68
68
|
to_expr.plus(Expression.cast(other))
|
|
69
69
|
end
|
|
@@ -148,9 +148,10 @@ module Quicopt
|
|
|
148
148
|
# Builds an *equality row*, not a boolean — +x == 5+ is a constraint.
|
|
149
149
|
#
|
|
150
150
|
# A non-numeric, non-expression operand falls back to identity, so an
|
|
151
|
-
# incidental comparison (
|
|
152
|
-
# of raising. Hash and Set membership are unaffected
|
|
153
|
-
#
|
|
151
|
+
# incidental comparison (<tt>x == nil</tt>, <tt>x == "x"</tt>) still
|
|
152
|
+
# answers +false+ instead of raising. Hash and Set membership are unaffected
|
|
153
|
+
# either way: they key off <tt>hash</tt>/<tt>eql?</tt>, which +Variable+
|
|
154
|
+
# defines separately.
|
|
154
155
|
#
|
|
155
156
|
# @return [Constraint, Boolean]
|
|
156
157
|
def ==(other)
|
|
@@ -171,7 +172,8 @@ module Quicopt
|
|
|
171
172
|
end
|
|
172
173
|
|
|
173
174
|
# Lets Ruby fold a numeric on the left into an expression, so +3 * x+ and
|
|
174
|
-
# +10 - x+ dispatch here instead of failing inside
|
|
175
|
+
# +10 - x+ dispatch here instead of failing inside
|
|
176
|
+
# <tt>Integer</tt>/<tt>Float</tt>.
|
|
175
177
|
#
|
|
176
178
|
# @param other [Numeric]
|
|
177
179
|
# @return [Array(Expression, Expression)]
|
|
@@ -272,11 +274,11 @@ module Quicopt
|
|
|
272
274
|
end
|
|
273
275
|
|
|
274
276
|
# A polynomial of degree ≤ 2 over a model's variables, always kept in collected
|
|
275
|
-
# form:
|
|
277
|
+
# form: <tt>constant + Σ cᵢ·xᵢ + Σ qᵢⱼ·xᵢxⱼ</tt>.
|
|
276
278
|
#
|
|
277
|
-
# A value read from a permutation (
|
|
278
|
-
# (
|
|
279
|
-
# linear, +x * dist[i, j]+ quadratic.
|
|
279
|
+
# A value read from a permutation (<tt>route.item_at(0)</tt>) or a lookup
|
|
280
|
+
# table (<tt>dist[i, j]</tt>) counts as one factor, like a variable:
|
|
281
|
+
# +2 * dist[i, j]+ is linear, +x * dist[i, j]+ quadratic.
|
|
280
282
|
#
|
|
281
283
|
# Instances are immutable — every operator returns a new expression — so an
|
|
282
284
|
# expression can be shared, reused and stored without aliasing surprises.
|
|
@@ -335,7 +337,7 @@ module Quicopt
|
|
|
335
337
|
degree.zero?
|
|
336
338
|
end
|
|
337
339
|
|
|
338
|
-
# @return [Expression]
|
|
340
|
+
# @return [Expression] <tt>self + other</tt>
|
|
339
341
|
def plus(other)
|
|
340
342
|
Expression.new(constant: @constant + other.constant,
|
|
341
343
|
linear: Expression.merge(@linear, other.linear),
|
|
@@ -401,7 +403,7 @@ module Quicopt
|
|
|
401
403
|
seen
|
|
402
404
|
end
|
|
403
405
|
|
|
404
|
-
# Renders the polynomial, e.g. +3
|
|
406
|
+
# Renders the polynomial, e.g. <tt>-4 + 3\*x + 2\*y</tt>.
|
|
405
407
|
def to_s
|
|
406
408
|
terms = []
|
|
407
409
|
terms << Format.number(@constant) unless @constant.zero?
|
|
@@ -614,10 +616,11 @@ module Quicopt
|
|
|
614
616
|
@by_name[name.to_s]
|
|
615
617
|
end
|
|
616
618
|
|
|
617
|
-
# Declare an
|
|
618
|
-
# slot, both numbered from 0. For a route the items are
|
|
619
|
-
# slots are the visits, so slot 0 holds the first stop
|
|
620
|
-
# the items are the jobs and the slots the positions
|
|
619
|
+
# Declare an ordering as a decision: +n_items+ items placed in as many
|
|
620
|
+
# slots, one item per slot, both numbered from 0. For a route the items are
|
|
621
|
+
# the stops and the slots are the visits, so slot 0 holds the first stop
|
|
622
|
+
# visited; for a machine the items are the jobs and the slots the positions
|
|
623
|
+
# in its queue.
|
|
621
624
|
#
|
|
622
625
|
# Five stops along a road; find the shortest route through all of them that
|
|
623
626
|
# visits stop 3 before stop 0:
|
|
@@ -629,7 +632,7 @@ module Quicopt
|
|
|
629
632
|
# route.precede(3, 0) # stop 3 is visited before stop 0
|
|
630
633
|
# m.minimize((0...4).sum { |k| dist[route.item_at(k), route.item_at(k + 1)] })
|
|
631
634
|
#
|
|
632
|
-
# result = Quicopt::Client.new.solve(m)
|
|
635
|
+
# result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
633
636
|
# result.objective # 4.0
|
|
634
637
|
# result.structures["route"].item_at # [3, 1, 4, 2, 0]: the stops in the order visited
|
|
635
638
|
#
|
|
@@ -639,31 +642,33 @@ module Quicopt
|
|
|
639
642
|
# @param name [String, Symbol] identifies the permutation in the result, as
|
|
640
643
|
# +result.structures[name]+; shares one namespace with the model's variables
|
|
641
644
|
# and lookup tables
|
|
642
|
-
# @param
|
|
645
|
+
# @param n_items [Integer] the number of items, at least 2
|
|
643
646
|
# @param start [Array<Integer>, nil] the arrangement the service's search
|
|
644
647
|
# starts from: +start[i]+ is the slot of item +i+, so it holds each of
|
|
645
|
-
# +0...
|
|
648
|
+
# +0...n_items+ once. Left out, item +i+ starts in slot +i+.
|
|
646
649
|
# @return [Permutation]
|
|
647
|
-
# @raise [TypeError] if +
|
|
648
|
-
# @raise [ArgumentError] if +
|
|
649
|
-
# not an arrangement of +0...
|
|
650
|
-
def permutation(name,
|
|
651
|
-
|
|
652
|
-
|
|
650
|
+
# @raise [TypeError] if +n_items+ is not an Integer
|
|
651
|
+
# @raise [ArgumentError] if +n_items+ is below 2, the name is taken, or
|
|
652
|
+
# +start+ is not an arrangement of +0...n_items+
|
|
653
|
+
def permutation(name, n_items, start: nil)
|
|
654
|
+
unless n_items.is_a?(Integer)
|
|
655
|
+
raise TypeError, "a permutation's number of items is a whole number, got #{n_items.inspect}"
|
|
656
|
+
end
|
|
657
|
+
raise ArgumentError, "a permutation has at least 2 items, got #{n_items}" if n_items < 2
|
|
653
658
|
|
|
654
659
|
nm = claim(name)
|
|
655
|
-
unless start.nil? || (start.is_a?(Array) && start.all?(Integer) && start.sort == (0...
|
|
656
|
-
raise ArgumentError, "permutation #{nm.inspect}: start must hold each of 0 to #{
|
|
660
|
+
unless start.nil? || (start.is_a?(Array) && start.all?(Integer) && start.sort == (0...n_items).to_a)
|
|
661
|
+
raise ArgumentError, "permutation #{nm.inspect}: start must hold each of 0 to #{n_items - 1} once " \
|
|
657
662
|
"(start[i] is the slot item i starts in)"
|
|
658
663
|
end
|
|
659
|
-
permutation = Permutation.new(self, nm,
|
|
664
|
+
permutation = Permutation.new(self, nm, n_items, start&.dup)
|
|
660
665
|
@permutations << permutation
|
|
661
666
|
@structured[nm] = permutation
|
|
662
667
|
end
|
|
663
668
|
|
|
664
|
-
# Declare an assignment of +
|
|
665
|
-
# team each person joins, which truck each parcel rides in. Items and
|
|
666
|
-
# are both numbered from 0, and a part may stay empty.
|
|
669
|
+
# Declare an assignment of +n_items+ items to +n_parts+ parts as a decision:
|
|
670
|
+
# which team each person joins, which truck each parcel rides in. Items and
|
|
671
|
+
# parts are both numbered from 0, and a part may stay empty.
|
|
667
672
|
#
|
|
668
673
|
# Six players of strengths 7, 5, 4, 3, 2 and 1 form a team of four and a
|
|
669
674
|
# team of two, as evenly matched as possible:
|
|
@@ -675,34 +680,39 @@ module Quicopt
|
|
|
675
680
|
# m.add(teams.size_of(0) == 4) # team 0 has four players
|
|
676
681
|
# m.minimize(Quicopt.max(total[0], total[1]))
|
|
677
682
|
#
|
|
678
|
-
# result = Quicopt::Client.new.solve(m)
|
|
683
|
+
# result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
679
684
|
# result.objective # 11.0
|
|
680
685
|
# result.structures["teams"].part_of # [1, 0, 1, 0, 0, 0]: players 0 and 2 form team 1
|
|
681
686
|
#
|
|
682
687
|
# @param name [String, Symbol] identifies the partition in the result, as
|
|
683
688
|
# +result.structures[name]+; shares one namespace with the model's
|
|
684
689
|
# variables, permutations and lookup tables
|
|
685
|
-
# @param
|
|
686
|
-
# @param
|
|
690
|
+
# @param n_items [Integer] the number of items, at least 1
|
|
691
|
+
# @param n_parts [Integer] the number of parts, at least 2
|
|
687
692
|
# @param start [Array<Integer>, nil] the assignment the service's search
|
|
688
|
-
# starts from: +start[i]+ is the part of item +i+, one of +0...
|
|
689
|
-
# Left out, item +i+ starts in part +i %
|
|
693
|
+
# starts from: +start[i]+ is the part of item +i+, one of +0...n_parts+.
|
|
694
|
+
# Left out, item +i+ starts in part +i % n_parts+.
|
|
690
695
|
# @return [Partition]
|
|
691
|
-
# @raise [TypeError] if +
|
|
692
|
-
# @raise [ArgumentError] if +
|
|
693
|
-
# taken, or +start+ does not give each item a part
|
|
694
|
-
def partition(name,
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
696
|
+
# @raise [TypeError] if +n_items+ or +n_parts+ is not an Integer
|
|
697
|
+
# @raise [ArgumentError] if +n_items+ is below 1, +n_parts+ below 2, the
|
|
698
|
+
# name is taken, or +start+ does not give each item a part
|
|
699
|
+
def partition(name, n_items, n_parts, start: nil)
|
|
700
|
+
unless n_items.is_a?(Integer)
|
|
701
|
+
raise TypeError, "a partition's number of items is a whole number, got #{n_items.inspect}"
|
|
702
|
+
end
|
|
703
|
+
unless n_parts.is_a?(Integer)
|
|
704
|
+
raise TypeError, "a partition's number of parts is a whole number, got #{n_parts.inspect}"
|
|
705
|
+
end
|
|
706
|
+
raise ArgumentError, "a partition has at least 1 item, got #{n_items}" if n_items < 1
|
|
707
|
+
raise ArgumentError, "a partition has at least 2 parts, got #{n_parts}" if n_parts < 2
|
|
699
708
|
|
|
700
709
|
nm = claim(name)
|
|
701
|
-
unless start.nil? || (start.is_a?(Array) && start.size ==
|
|
702
|
-
|
|
703
|
-
|
|
710
|
+
unless start.nil? || (start.is_a?(Array) && start.size == n_items &&
|
|
711
|
+
start.all? { |b| b.is_a?(Integer) && b >= 0 && b < n_parts })
|
|
712
|
+
raise ArgumentError, "partition #{nm.inspect}: start gives each of the #{n_items} items a part " \
|
|
713
|
+
"from 0 to #{n_parts - 1}"
|
|
704
714
|
end
|
|
705
|
-
partition = Partition.new(self, nm,
|
|
715
|
+
partition = Partition.new(self, nm, n_items, n_parts, start&.dup)
|
|
706
716
|
@partitions << partition
|
|
707
717
|
@structured[nm] = partition
|
|
708
718
|
end
|
|
@@ -753,9 +763,9 @@ module Quicopt
|
|
|
753
763
|
|
|
754
764
|
# Declare a table of data that can be indexed by decisions. A model sometimes
|
|
755
765
|
# needs a number from a table at a position that is not known yet: the distance
|
|
756
|
-
# between the first and the second stop of a route whose
|
|
757
|
-
# still choosing, or the price of an option an integer variable picks. A
|
|
758
|
-
# array cannot be indexed this way; the table returned here can.
|
|
766
|
+
# between the first and the second stop of a route whose ordering the service
|
|
767
|
+
# is still choosing, or the price of an option an integer variable picks. A
|
|
768
|
+
# Ruby array cannot be indexed this way; the table returned here can.
|
|
759
769
|
#
|
|
760
770
|
# price = m.lookup_table("price", [3.0, 1.0, 4.0, 1.5])
|
|
761
771
|
# choice = m.int_var(0, 3, "choice")
|
|
@@ -771,8 +781,8 @@ module Quicopt
|
|
|
771
781
|
# @param name [String, Symbol] the table's name; shares one namespace with the
|
|
772
782
|
# model's variables and permutations
|
|
773
783
|
# @param values [Array<Numeric>, Array<Array<Numeric>>] a vector (indexed
|
|
774
|
-
# +table[i]+) or a matrix (
|
|
775
|
-
# numbers, none of them NaN
|
|
784
|
+
# +table[i]+) or a matrix (<tt>table[i, j]</tt>, every row the same
|
|
785
|
+
# length) of numbers, none of them NaN
|
|
776
786
|
# @return [LookupTable]
|
|
777
787
|
# @raise [TypeError] if +values+ is neither a vector nor a matrix of numbers
|
|
778
788
|
# @raise [ArgumentError] if it is empty, ragged or holds NaN, or the name is
|
|
@@ -864,10 +874,11 @@ module Quicopt
|
|
|
864
874
|
lines << " #{v.name} in #{v.domain} #{range}"
|
|
865
875
|
end
|
|
866
876
|
@permutations.each do |p|
|
|
867
|
-
|
|
868
|
-
|
|
877
|
+
rules = p.precedences.map { |a, b| "#{a} before #{b}" }
|
|
878
|
+
rules = rules.empty? ? "" : ", with #{rules.join(', ')}"
|
|
879
|
+
lines << " #{p.name} is an ordering of items 0 to #{p.n_items - 1}#{rules}"
|
|
869
880
|
end
|
|
870
|
-
@partitions.each { |p| lines << " #{p.name} puts items 0 to #{p.
|
|
881
|
+
@partitions.each { |p| lines << " #{p.name} puts items 0 to #{p.n_items - 1} in parts 0 to #{p.n_parts - 1}" }
|
|
871
882
|
@tables.each { |t| lines << " #{t.name} is a #{t.shape.join(' × ')} lookup table" }
|
|
872
883
|
@blocks.each { |b| lines << " #{b.name} is a block of #{b.variables.join(', ')}" }
|
|
873
884
|
lines.join("\n")
|
|
@@ -918,9 +929,9 @@ module Quicopt
|
|
|
918
929
|
end
|
|
919
930
|
|
|
920
931
|
def auto_name
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
"x#{
|
|
932
|
+
number = @variables.size + 1
|
|
933
|
+
number += 1 while @by_name.key?("x#{number}") || @structured.key?("x#{number}")
|
|
934
|
+
"x#{number}"
|
|
924
935
|
end
|
|
925
936
|
|
|
926
937
|
# +name+ as a String, checked to be free among the model's variables,
|
data/lib/quicopt/structured.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
3
|
# frozen_string_literal: true
|
|
4
4
|
|
|
5
|
-
# quicopt/structured —
|
|
5
|
+
# quicopt/structured — orderings as decisions (+Permutation+), assignments to
|
|
6
6
|
# groups as decisions (+Partition+) and data read at decided positions
|
|
7
7
|
# (+LookupTable+).
|
|
8
8
|
#
|
|
@@ -44,9 +44,9 @@ module Quicopt
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# A permutation declared by +Model#permutation+: +
|
|
48
|
-
# slots, one item per slot, both numbered from 0. Read it with +item_at+
|
|
49
|
-
# +slot_of+; constrain it with +precede+.
|
|
47
|
+
# A permutation declared by +Model#permutation+: +n_items+ items placed in as
|
|
48
|
+
# many slots, one item per slot, both numbered from 0. Read it with +item_at+
|
|
49
|
+
# and +slot_of+; constrain it with +precede+.
|
|
50
50
|
#
|
|
51
51
|
# route = m.permutation("route", 5)
|
|
52
52
|
# route.item_at(0) # the first stop visited
|
|
@@ -58,7 +58,7 @@ module Quicopt
|
|
|
58
58
|
# @return [String] the permutation's name; its arrangement comes back keyed by it
|
|
59
59
|
attr_reader :name
|
|
60
60
|
# @return [Integer] the number of items, which is also the number of slots
|
|
61
|
-
attr_reader :
|
|
61
|
+
attr_reader :n_items
|
|
62
62
|
# @return [Array<Integer>, nil] the slot each item starts in, or +nil+ for the
|
|
63
63
|
# default (item +i+ in slot +i+)
|
|
64
64
|
attr_reader :start
|
|
@@ -66,10 +66,10 @@ module Quicopt
|
|
|
66
66
|
# Declared with +Model#permutation+, not built directly.
|
|
67
67
|
#
|
|
68
68
|
# @api private
|
|
69
|
-
def initialize(model, name,
|
|
69
|
+
def initialize(model, name, n_items, start)
|
|
70
70
|
@model = model
|
|
71
71
|
@name = name
|
|
72
|
-
@
|
|
72
|
+
@n_items = n_items
|
|
73
73
|
@start = start&.freeze
|
|
74
74
|
@precedences = []
|
|
75
75
|
@reads = {}
|
|
@@ -79,22 +79,22 @@ module Quicopt
|
|
|
79
79
|
# a route whose items are stops, +route.item_at(0)+ is the first stop visited.
|
|
80
80
|
# Index a +LookupTable+ with it to read data about that item.
|
|
81
81
|
#
|
|
82
|
-
# @param slot [Integer] from 0 to +
|
|
82
|
+
# @param slot [Integer] from 0 to +n_items - 1+
|
|
83
83
|
# @return [Expression]
|
|
84
84
|
# @raise [TypeError, ArgumentError] if +slot+ is not one of the slots
|
|
85
85
|
def item_at(slot)
|
|
86
|
-
read(:item_at, Positions.check(slot, @
|
|
86
|
+
read(:item_at, Positions.check(slot, @n_items, "slot", owner))
|
|
87
87
|
end
|
|
88
88
|
|
|
89
89
|
# The slot item +item+ is placed in, an expression whose value the service
|
|
90
90
|
# chooses. For a route whose items are stops, +route.slot_of(3)+ is when stop 3
|
|
91
91
|
# is visited.
|
|
92
92
|
#
|
|
93
|
-
# @param item [Integer] from 0 to +
|
|
93
|
+
# @param item [Integer] from 0 to +n_items - 1+
|
|
94
94
|
# @return [Expression]
|
|
95
95
|
# @raise [TypeError, ArgumentError] if +item+ is not one of the items
|
|
96
96
|
def slot_of(item)
|
|
97
|
-
read(:slot_of, Positions.check(item, @
|
|
97
|
+
read(:slot_of, Positions.check(item, @n_items, "item", owner))
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
# Require item +before+ to be in an earlier slot than item +after+: a pickup
|
|
@@ -109,13 +109,13 @@ module Quicopt
|
|
|
109
109
|
# contradicts those already given (1 before 2 and 2 before 1), or if the
|
|
110
110
|
# +start+ places +before+ after +after+
|
|
111
111
|
def precede(before, after)
|
|
112
|
-
a = Positions.check(before, @
|
|
113
|
-
b = Positions.check(after, @
|
|
112
|
+
a = Positions.check(before, @n_items, "item", owner)
|
|
113
|
+
b = Positions.check(after, @n_items, "item", owner)
|
|
114
114
|
raise ArgumentError, "an item cannot precede itself" if a == b
|
|
115
115
|
if @start && @start[a] > @start[b]
|
|
116
116
|
raise ArgumentError, "#{owner}: the start places item #{a} after item #{b}, which it must precede"
|
|
117
117
|
end
|
|
118
|
-
if Permutation.cycle?(@precedences + [[a, b]], @
|
|
118
|
+
if Permutation.cycle?(@precedences + [[a, b]], @n_items)
|
|
119
119
|
raise ArgumentError, "requiring #{a} before #{b} contradicts the requirements already given for #{owner}"
|
|
120
120
|
end
|
|
121
121
|
|
|
@@ -129,20 +129,20 @@ module Quicopt
|
|
|
129
129
|
@precedences.dup.freeze
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
# @return [String] name and
|
|
132
|
+
# @return [String] name and number of items
|
|
133
133
|
def inspect
|
|
134
|
-
"#<Quicopt::Permutation #{@name} (#{@
|
|
134
|
+
"#<Quicopt::Permutation #{@name} (#{@n_items} items in #{@n_items} slots)>"
|
|
135
135
|
end
|
|
136
136
|
|
|
137
|
-
# Whether the +[before, after]+ requirements over items +0...
|
|
138
|
-
# each other: Kahn's algorithm leaves an item unplaced exactly
|
|
139
|
-
# contain a cycle.
|
|
137
|
+
# Whether the +[before, after]+ requirements over items +0...n_items+
|
|
138
|
+
# contradict each other: Kahn's algorithm leaves an item unplaced exactly
|
|
139
|
+
# when they contain a cycle.
|
|
140
140
|
#
|
|
141
141
|
# @api private
|
|
142
|
-
def self.cycle?(edges,
|
|
143
|
-
indegree = Array.new(
|
|
142
|
+
def self.cycle?(edges, n_items)
|
|
143
|
+
indegree = Array.new(n_items, 0)
|
|
144
144
|
edges.each { |_, b| indegree[b] += 1 }
|
|
145
|
-
ready = (0...
|
|
145
|
+
ready = (0...n_items).select { |i| indegree[i].zero? }
|
|
146
146
|
placed = 0
|
|
147
147
|
until ready.empty?
|
|
148
148
|
x = ready.pop
|
|
@@ -154,7 +154,7 @@ module Quicopt
|
|
|
154
154
|
ready << b if indegree[b].zero?
|
|
155
155
|
end
|
|
156
156
|
end
|
|
157
|
-
placed <
|
|
157
|
+
placed < n_items
|
|
158
158
|
end
|
|
159
159
|
|
|
160
160
|
private
|
|
@@ -169,8 +169,8 @@ module Quicopt
|
|
|
169
169
|
end
|
|
170
170
|
end
|
|
171
171
|
|
|
172
|
-
# A partition declared by +Model#partition+: +
|
|
173
|
-
# both numbered from 0. Read it with +part_of+ and +size_of+.
|
|
172
|
+
# A partition declared by +Model#partition+: +n_items+ items in +n_parts+
|
|
173
|
+
# parts, both numbered from 0. Read it with +part_of+ and +size_of+.
|
|
174
174
|
#
|
|
175
175
|
# teams = m.partition("teams", 6, 2)
|
|
176
176
|
# teams.part_of(4) # the team person 4 joins
|
|
@@ -181,21 +181,21 @@ module Quicopt
|
|
|
181
181
|
# @return [String] the partition's name; its assignment comes back keyed by it
|
|
182
182
|
attr_reader :name
|
|
183
183
|
# @return [Integer] the number of items
|
|
184
|
-
attr_reader :
|
|
184
|
+
attr_reader :n_items
|
|
185
185
|
# @return [Integer] the number of parts
|
|
186
|
-
attr_reader :
|
|
186
|
+
attr_reader :n_parts
|
|
187
187
|
# @return [Array<Integer>, nil] the part each item starts in, or +nil+ for the
|
|
188
|
-
# default (item +i+ in part +i %
|
|
188
|
+
# default (item +i+ in part +i % n_parts+)
|
|
189
189
|
attr_reader :start
|
|
190
190
|
|
|
191
191
|
# Declared with +Model#partition+, not built directly.
|
|
192
192
|
#
|
|
193
193
|
# @api private
|
|
194
|
-
def initialize(model, name,
|
|
194
|
+
def initialize(model, name, n_items, n_parts, start)
|
|
195
195
|
@model = model
|
|
196
196
|
@name = name
|
|
197
|
-
@
|
|
198
|
-
@
|
|
197
|
+
@n_items = n_items
|
|
198
|
+
@n_parts = n_parts
|
|
199
199
|
@start = start&.freeze
|
|
200
200
|
@reads = {}
|
|
201
201
|
end
|
|
@@ -203,26 +203,26 @@ module Quicopt
|
|
|
203
203
|
# The part item +item+ is in, an expression whose value the service
|
|
204
204
|
# chooses. Index a +LookupTable+ with it to read data about that part.
|
|
205
205
|
#
|
|
206
|
-
# @param item [Integer] from 0 to +
|
|
206
|
+
# @param item [Integer] from 0 to +n_items - 1+
|
|
207
207
|
# @return [Expression]
|
|
208
208
|
# @raise [TypeError, ArgumentError] if +item+ is not one of the items
|
|
209
209
|
def part_of(item)
|
|
210
|
-
read(:part_of, Positions.check(item, @
|
|
210
|
+
read(:part_of, Positions.check(item, @n_items, "item", owner))
|
|
211
211
|
end
|
|
212
212
|
|
|
213
213
|
# The number of items in part +part+, an expression whose value the service
|
|
214
|
-
# chooses: a count from 0 to +
|
|
214
|
+
# chooses: a count from 0 to +n_items+.
|
|
215
215
|
#
|
|
216
|
-
# @param part [Integer] from 0 to +
|
|
216
|
+
# @param part [Integer] from 0 to +n_parts - 1+
|
|
217
217
|
# @return [Expression]
|
|
218
218
|
# @raise [TypeError, ArgumentError] if +part+ is not one of the parts
|
|
219
219
|
def size_of(part)
|
|
220
|
-
read(:size_of, Positions.check(part, @
|
|
220
|
+
read(:size_of, Positions.check(part, @n_parts, "part", owner))
|
|
221
221
|
end
|
|
222
222
|
|
|
223
|
-
# @return [String] name and
|
|
223
|
+
# @return [String] name, number of items and number of parts
|
|
224
224
|
def inspect
|
|
225
|
-
"#<Quicopt::Partition #{@name} (#{@
|
|
225
|
+
"#<Quicopt::Partition #{@name} (#{@n_items} items in #{@n_parts} parts)>"
|
|
226
226
|
end
|
|
227
227
|
|
|
228
228
|
private
|
|
@@ -252,7 +252,8 @@ module Quicopt
|
|
|
252
252
|
attr_reader :name
|
|
253
253
|
# @return [Array<Float>, Array<Array<Float>>] the entries, as given
|
|
254
254
|
attr_reader :values
|
|
255
|
-
# @return [Array<Integer>] the extent of each dimension: +[
|
|
255
|
+
# @return [Array<Integer>] the extent of each dimension: +[length]+ or
|
|
256
|
+
# +[rows, columns]+
|
|
256
257
|
attr_reader :shape
|
|
257
258
|
|
|
258
259
|
# Declared with +Model#lookup_table+, not built directly.
|
data/lib/quicopt/version.rb
CHANGED
data/lib/quicopt/wire.rb
CHANGED
|
@@ -96,9 +96,10 @@ module Quicopt
|
|
|
96
96
|
#
|
|
97
97
|
# IEEE keeps +-0.0+ distinct from +0.0+ — a distinct bit pattern, and a
|
|
98
98
|
# distinct protobuf value — and moving a bound across an inequality
|
|
99
|
-
# (
|
|
100
|
-
# Python and Julia clients send +0.0+ there, so collapse the
|
|
101
|
-
# rather than sending bytes that differ from theirs by a sign
|
|
99
|
+
# (<tt>f ≤ 0</tt> ⇒ <tt>0 − f ≥ 0</tt>) produces one whenever that bound
|
|
100
|
+
# is zero. The Python and Julia clients send +0.0+ there, so collapse the
|
|
101
|
+
# sign here rather than sending bytes that differ from theirs by a sign
|
|
102
|
+
# bit.
|
|
102
103
|
def number(value)
|
|
103
104
|
float = value.to_f
|
|
104
105
|
float.zero? ? 0.0 : float
|
|
@@ -241,12 +242,12 @@ module Quicopt
|
|
|
241
242
|
def structure_decl(structure)
|
|
242
243
|
start = (structure.start || []).map { |k| k + 1 }
|
|
243
244
|
if structure.is_a?(Partition)
|
|
244
|
-
return PB::StructureDecl.new(name: structure.name, kind: "partition", size: structure.
|
|
245
|
-
start: start, fixed: false, parts: structure.
|
|
245
|
+
return PB::StructureDecl.new(name: structure.name, kind: "partition", size: structure.n_items,
|
|
246
|
+
start: start, fixed: false, parts: structure.n_parts)
|
|
246
247
|
end
|
|
247
248
|
|
|
248
249
|
PB::StructureDecl.new(
|
|
249
|
-
name: structure.name, kind: "permutation", size: structure.
|
|
250
|
+
name: structure.name, kind: "permutation", size: structure.n_items, start: start, fixed: false,
|
|
250
251
|
precede: structure.precedences.map { |a, b| PB::Precedence.new(before: a + 1, after: b + 1) }
|
|
251
252
|
)
|
|
252
253
|
end
|
data/lib/quicopt.rb
CHANGED
|
@@ -22,7 +22,7 @@ require_relative "quicopt/client"
|
|
|
22
22
|
# m.maximize(3 * x + 2 * y + z)
|
|
23
23
|
# m.add(x + y <= 5)
|
|
24
24
|
#
|
|
25
|
-
# result = Quicopt::Client.new.solve(m)
|
|
25
|
+
# result = Quicopt::Client.new(api_key: "…").solve(m)
|
|
26
26
|
# puts result.display
|
|
27
27
|
#
|
|
28
28
|
# Three pieces, nothing more: +Model+ authors, +Wire+ encodes, +Client+ ships.
|