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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 370fa0c8906d43f509f9262406805bdc0e1357248e8271125b6b76a096a2fd6f
4
- data.tar.gz: 0df9e60f7456ae1b0ecae3a9d3ffb668490c684a095e71f4ebfb614f522c15f0
3
+ metadata.gz: 16cdf553d639def3a3a5cbbdb7c9e4240118dc619595edeb9dd710cee798362d
4
+ data.tar.gz: 96ce7ae36eb75c68c493a749be503b28240cf843acd5d1196dfaf63410896fcc
5
5
  SHA512:
6
- metadata.gz: d4d0c6ef70c1f502da08557c366f31695293a52886b72bc6f214d89c902eedb21862dbe3a14223184adaa3eb64873dccf52a54609ce836e4363f92f77ef9344b
7
- data.tar.gz: e1d1ac282698f36bbe968c9099029db5f3e12bbbe6fcb7fded6dd83dfe1e49b66bdba22e267ca64845deb12cfd6d93adc24b434066470417dfbad162deeeb40f
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
- orders as decisions, and data read along them.
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 order
248
+ ## Choosing an ordering
247
249
 
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:
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 `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:
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 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.
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 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"`.
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` 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:
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 # the public free tier
363
- client = Quicopt::Client.new("http://localhost:8137")
364
- client = Quicopt::Client.new(url, "my-api-key", timeout: 120)
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.** The first call without a key mints one, cached at
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, runs for all of it:
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)
@@ -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+ / …). The first keyless call mints an API key, returned
19
- # in the +X-Quicopt-Api-Key+ response header; it is cached at
20
- # +$XDG_CACHE_HOME/quicopt/free_key+ and replayed as +Authorization: Bearer+ on
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 public Quicopt free-tier endpoint a +Client+ targets when no +base_url+
39
- # is given. Mirrors the Python and Julia clients' default, so every client
40
- # reaches the same server out of the box.
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 (+LP+/+MILP+/+QUBO+/…), or +nil+
128
- # if it did not report one.
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 (+size_exceeded+,
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 overriding where the free key is cached. Point it at
162
- # durable storage in an environment whose home directory does not survive the
163
- # run (CI, containers), where the default location is wiped between sessions
164
- # and every run would otherwise mint a fresh key.
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+ — the public free tier
168
- # (+DEFAULT_BASE_URL+) unless another URL is given.
170
+ # A connection to a Quicopt service at +base_url+, by default
171
+ # <tt>DEFAULT_BASE_URL</tt>.
169
172
  #
170
- # The client holds the API key: pass a known one, or let the first keyless call
171
- # mint one — which is then cached on disk and reused by later runs, so one
172
- # caller keeps one key.
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; set on the first mint
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 free key is cached
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 api_key [String, nil] a known API key, or +nil+ to reuse the cached
202
- # free key (minting one on the first keyless call). A key passed here is
203
- # used as-is and never written to the cache, so authenticating with a key
204
- # you already hold cannot clobber the free key of whoever runs the code.
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 cache the free key;
207
- # defaults to +Client.default_key_path+
208
- # @param cache [Boolean] +false+ keeps the key in memory only, neither
209
- # reading nor writing the cache file
210
- def initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0,
211
- key_path: nil, cache: true)
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, runs
234
- # for all of it. The service grants at most its maximum (120 s on the free
235
- # tier), and a warning says so when it granted less. The client waits for
236
- # the answer at least this long plus a minute.
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 +'+'+ — the form
365
- # the service's query parser round-trips unambiguously, and the form the
366
- # other clients send. Ruby's stock helpers (+URI.encode_www_form+,
367
- # +CGI.escape+, +URI.encode_www_form_component+) all emit +'+'+ here, so none
368
- # of them can be used.
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 (+queued+/+running+/+done+/+failed+) and its
562
- # log tail, as returned by the service
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-+not_done+ error, or once +timeout+ elapses
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
@@ -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 +a > b + tol+.
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. +fee * Quicopt.min(1, a.active + b.active)+ is a fee that is due
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 (+value = lhs − rhs+) holds.
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. +step(x + y - 1)+; the indicator of
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. +min(1, a.active + b.active)+
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 (+x+, +-x+, +3*x+).
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] +self + other+
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 (+x == nil+, +x == "x"+) still answers +false+ instead
152
- # of raising. Hash and Set membership are unaffected either way: they key off
153
- # +hash+/+eql?+, which +Variable+ defines separately.
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 +Integer+/+Float+.
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: +constant + Σ cᵢ·xᵢ + Σ qᵢⱼ·xᵢxⱼ+.
277
+ # form: <tt>constant + Σ cᵢ·xᵢ + Σ qᵢⱼ·xᵢxⱼ</tt>.
276
278
  #
277
- # A value read from a permutation (+route.item_at(0)+) or a lookup table
278
- # (+dist[i, j]+) counts as one factor, like a variable: +2 * dist[i, j]+ is
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] +self + other+
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*x + 2*y - 4+.
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 order as a decision: +n+ items placed in +n+ slots, one item per
618
- # slot, both numbered from 0. For a route the items are the stops and the
619
- # slots are the visits, so slot 0 holds the first stop visited; for a machine
620
- # the items are the jobs and the slots the positions in its queue.
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 n [Integer] the number of items, at least 2
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...n+ once. Left out, item +i+ starts in slot +i+.
648
+ # +0...n_items+ once. Left out, item +i+ starts in slot +i+.
646
649
  # @return [Permutation]
647
- # @raise [TypeError] if +n+ is not an Integer
648
- # @raise [ArgumentError] if +n+ is below 2, the name is taken, or +start+ is
649
- # not an arrangement of +0...n+
650
- def permutation(name, n, start: nil)
651
- raise TypeError, "a permutation's size is a whole number, got #{n.inspect}" unless n.is_a?(Integer)
652
- raise ArgumentError, "a permutation has at least 2 items, got #{n}" if n < 2
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...n).to_a)
656
- raise ArgumentError, "permutation #{nm.inspect}: start must hold each of 0 to #{n - 1} once " \
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, n, start&.dup)
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 +n+ items to +parts+ parts as a decision: which
665
- # team each person joins, which truck each parcel rides in. Items and parts
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 n [Integer] the number of items, at least 1
686
- # @param parts [Integer] the number of parts, at least 2
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...parts+.
689
- # Left out, item +i+ starts in part +i % parts+.
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 +n+ or +parts+ is not an Integer
692
- # @raise [ArgumentError] if +n+ is below 1, +parts+ below 2, the name is
693
- # taken, or +start+ does not give each item a part
694
- def partition(name, n, parts, start: nil)
695
- raise TypeError, "a partition's number of items is a whole number, got #{n.inspect}" unless n.is_a?(Integer)
696
- raise TypeError, "a partition's number of parts is a whole number, got #{parts.inspect}" unless parts.is_a?(Integer)
697
- raise ArgumentError, "a partition has at least 1 item, got #{n}" if n < 1
698
- raise ArgumentError, "a partition has at least 2 parts, got #{parts}" if parts < 2
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 == n && start.all? { |b| b.is_a?(Integer) && b >= 0 && b < parts })
702
- raise ArgumentError, "partition #{nm.inspect}: start gives each of the #{n} items a part " \
703
- "from 0 to #{parts - 1}"
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, n, parts, start&.dup)
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 order the service is
757
- # still choosing, or the price of an option an integer variable picks. A Ruby
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 (+table[i, j]+, every row the same length) of
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
- order = p.precedences.map { |a, b| ", #{a} before #{b}" }.join
868
- lines << " #{p.name} orders items 0 to #{p.size - 1}#{order}"
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.size - 1} in parts 0 to #{p.parts - 1}" }
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
- n = @variables.size + 1
922
- n += 1 while @by_name.key?("x#{n}") || @structured.key?("x#{n}")
923
- "x#{n}"
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,
@@ -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 — orders as decisions (+Permutation+), assignments to
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+: +size+ items placed in +size+
48
- # slots, one item per slot, both numbered from 0. Read it with +item_at+ and
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 :size
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, size, start)
69
+ def initialize(model, name, n_items, start)
70
70
  @model = model
71
71
  @name = name
72
- @size = size
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 +size - 1+
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, @size, "slot", owner))
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 +size - 1+
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, @size, "item", owner))
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, @size, "item", owner)
113
- b = Positions.check(after, @size, "item", owner)
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]], @size)
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 size
132
+ # @return [String] name and number of items
133
133
  def inspect
134
- "#<Quicopt::Permutation #{@name} (#{@size} items in #{@size} slots)>"
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...n+ contradict
138
- # each other: Kahn's algorithm leaves an item unplaced exactly when they
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, n)
143
- indegree = Array.new(n, 0)
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...n).select { |i| indegree[i].zero? }
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 < n
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+: +size+ items in +parts+ parts,
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 :size
184
+ attr_reader :n_items
185
185
  # @return [Integer] the number of parts
186
- attr_reader :parts
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 % parts+)
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, size, parts, start)
194
+ def initialize(model, name, n_items, n_parts, start)
195
195
  @model = model
196
196
  @name = name
197
- @size = size
198
- @parts = parts
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 +size - 1+
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, @size, "item", owner))
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 +size+.
214
+ # chooses: a count from 0 to +n_items+.
215
215
  #
216
- # @param part [Integer] from 0 to +parts - 1+
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, @parts, "part", owner))
220
+ read(:size_of, Positions.check(part, @n_parts, "part", owner))
221
221
  end
222
222
 
223
- # @return [String] name and sizes
223
+ # @return [String] name, number of items and number of parts
224
224
  def inspect
225
- "#<Quicopt::Partition #{@name} (#{@size} items in #{@parts} parts)>"
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: +[n]+ or +[rows, columns]+
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.
@@ -5,5 +5,5 @@
5
5
  module Quicopt
6
6
  # The gem version, tracking the client — not the wire schema, which is
7
7
  # versioned separately by its protobuf package (`quicopt.modeler.v1`).
8
- VERSION = "0.3.0"
8
+ VERSION = "0.4.0"
9
9
  end
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
- # (+f ≤ 0+ ⇒ +0 − f ≥ 0+) produces one whenever that bound is zero. The
100
- # Python and Julia clients send +0.0+ there, so collapse the sign here
101
- # rather than sending bytes that differ from theirs by a sign bit.
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.size,
245
- start: start, fixed: false, parts: structure.parts)
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.size, start: start, fixed: false,
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.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: quicopt
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tim Bode