quicopt 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +2 -1
- data/README.md +262 -33
- data/lib/quicopt/client.rb +156 -49
- data/lib/quicopt/indicators.rb +235 -0
- data/lib/quicopt/model.rb +442 -57
- data/lib/quicopt/modeler/v1/program_pb.rb +10 -1
- data/lib/quicopt/structured.rb +437 -0
- data/lib/quicopt/version.rb +1 -1
- data/lib/quicopt/wire.rb +171 -36
- data/lib/quicopt.rb +7 -6
- data/proto/quicopt/modeler/v1/program.proto +160 -6
- metadata +12 -7
data/lib/quicopt/client.rb
CHANGED
|
@@ -11,11 +11,11 @@ require "zlib"
|
|
|
11
11
|
|
|
12
12
|
# quicopt/client — talk to the Quicopt service over HTTP.
|
|
13
13
|
#
|
|
14
|
-
# Encode a model
|
|
15
|
-
#
|
|
16
|
-
# +google-protobuf+. The request body is the
|
|
17
|
-
#
|
|
18
|
-
#
|
|
14
|
+
# Encode a model (+Quicopt::Wire+), POST it, read the result back. Requests go
|
|
15
|
+
# out over stdlib +net/http+, so the gem's only runtime dependency stays
|
|
16
|
+
# +google-protobuf+. The request body is the encoded model; the response is the
|
|
17
|
+
# service's result JSON (+status+ / +objective+ / +solution+ / a ready-to-print
|
|
18
|
+
# +display+ / …). The first keyless call mints an API key, returned
|
|
19
19
|
# in the +X-Quicopt-Api-Key+ response header; it is cached at
|
|
20
20
|
# +$XDG_CACHE_HOME/quicopt/free_key+ and replayed as +Authorization: Bearer+ on
|
|
21
21
|
# every later call — including from a later *process*, so one caller keeps one key
|
|
@@ -28,26 +28,51 @@ require "zlib"
|
|
|
28
28
|
#
|
|
29
29
|
# A non-2xx response raises +QuicoptError+, carrying the service's stable +reason+
|
|
30
30
|
# code and the framed +display+ text.
|
|
31
|
+
#
|
|
32
|
+
# The service decides how long a solve may run unless the call passes
|
|
33
|
+
# +time_limit:+, which rides the query string like +project:+. The service grants
|
|
34
|
+
# at most its own maximum and reports both numbers in +solver_data+; the client
|
|
35
|
+
# warns when it granted less, and waits on the socket (or polls) long enough for
|
|
36
|
+
# the time it asked for.
|
|
31
37
|
module Quicopt
|
|
32
38
|
# The public Quicopt free-tier endpoint a +Client+ targets when no +base_url+
|
|
33
39
|
# is given. Mirrors the Python and Julia clients' default, so every client
|
|
34
40
|
# reaches the same server out of the box.
|
|
35
41
|
DEFAULT_BASE_URL = "https://try.quicoptapi.pgi.fz-juelich.de"
|
|
36
42
|
|
|
37
|
-
# The modelling
|
|
38
|
-
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
43
|
+
# The modelling library a model was written in, sent as the +source_language+
|
|
44
|
+
# call tag. It names the *library*, not the programming language — the service
|
|
45
|
+
# also sees +jump+, +pyomo+, +pulp+ and +mathopt+ — so this gem's own DSL
|
|
46
|
+
# carries its own name here.
|
|
41
47
|
SOURCE_LANGUAGE = "quicopt-ruby"
|
|
42
48
|
|
|
49
|
+
# How long a request may take beyond the solve itself — waiting in the queue,
|
|
50
|
+
# reading the model, writing the answer — before the client stops waiting on a
|
|
51
|
+
# solve whose time limit it set.
|
|
52
|
+
WAIT_MARGIN = 60.0
|
|
53
|
+
|
|
54
|
+
# The arrangement the service found for one permutation (see
|
|
55
|
+
# +Model#permutation+), both ways round and counted from 0: +item_at[k]+ is the
|
|
56
|
+
# item in slot +k+ — for a route, the stops in the order visited — and
|
|
57
|
+
# +slot_of[i]+ the slot of item +i+.
|
|
58
|
+
Arrangement = Data.define(:item_at, :slot_of)
|
|
59
|
+
|
|
60
|
+
# The assignment the service found for one partition (see +Model#partition+),
|
|
61
|
+
# parts counted from 0: +part_of[i]+ is the part of item +i+ and +size_of[b]+
|
|
62
|
+
# the number of items in part +b+.
|
|
63
|
+
Grouping = Data.define(:part_of, :size_of)
|
|
64
|
+
|
|
43
65
|
# A finished solve, parsed from the service's result JSON.
|
|
44
66
|
#
|
|
45
|
-
# +objective+ and +feasible+ are +nil+
|
|
46
|
-
#
|
|
47
|
-
# framed, ready-to-print summary the service renders
|
|
48
|
-
# print it rather than formatting your own.
|
|
67
|
+
# +objective+ and +feasible+ are +nil+ where the outcome leaves them undefined —
|
|
68
|
+
# a model with nothing to feasibility-check, or a run that found no solution.
|
|
69
|
+
# +display+ is the framed, ready-to-print summary the service renders the same
|
|
70
|
+
# way for every kind of model — print it rather than formatting your own.
|
|
71
|
+
# +structures+ holds the +Arrangement+ of each permutation and the +Grouping+
|
|
72
|
+
# of each partition the model declares, by name, and is empty for a model with
|
|
73
|
+
# none.
|
|
49
74
|
Result = Data.define(:job_id, :status, :objective, :feasible, :solution,
|
|
50
|
-
:solve_time_seconds, :solver_data, :display) do
|
|
75
|
+
:solve_time_seconds, :solver_data, :display, :structures) do
|
|
51
76
|
# Build a +Result+ from the service's decoded result JSON.
|
|
52
77
|
#
|
|
53
78
|
# Missing optional keys default rather than raise, so a partial result (e.g.
|
|
@@ -63,10 +88,43 @@ module Quicopt
|
|
|
63
88
|
solution: body["solution"] || {},
|
|
64
89
|
solve_time_seconds: body["solve_time_seconds"] || 0.0,
|
|
65
90
|
solver_data: body["solver_data"] || {},
|
|
66
|
-
display: body["display"].to_s
|
|
91
|
+
display: body["display"].to_s,
|
|
92
|
+
structures: (body["structures"] || {}).transform_values { |s| arrangement(s) })
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# One structure's value from the result JSON, where items, slots and parts
|
|
96
|
+
# count from 1: a permutation's +Arrangement+, a partition's +Grouping+, or
|
|
97
|
+
# +nil+ when none came back.
|
|
98
|
+
#
|
|
99
|
+
# @api private
|
|
100
|
+
def self.arrangement(json)
|
|
101
|
+
return nil if json.nil?
|
|
102
|
+
if json.key?("part_of")
|
|
103
|
+
return Grouping.new(part_of: json["part_of"].map { |b| b.to_i - 1 },
|
|
104
|
+
size_of: json["size_of"].map(&:to_i))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
Arrangement.new(item_at: json["item_at"].map { |i| i.to_i - 1 },
|
|
108
|
+
slot_of: json["slot_of"].map { |k| k.to_i - 1 })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Warn when the service ran this solve for less time than its +time_limit+
|
|
112
|
+
# asked for. The service grants at most its maximum and reports what was
|
|
113
|
+
# asked and what it granted side by side in +solver_data+.
|
|
114
|
+
#
|
|
115
|
+
# @return [Result] +self+
|
|
116
|
+
# @api private
|
|
117
|
+
def warn_if_lowered
|
|
118
|
+
asked = solver_data["time_limit_s"]
|
|
119
|
+
granted = solver_data["time_budget_s"]
|
|
120
|
+
if asked && granted && granted < asked
|
|
121
|
+
warn "quicopt: the service ran this solve for #{Format.number(granted.to_f)} s, not the " \
|
|
122
|
+
"#{Format.number(asked.to_f)} s asked for: #{Format.number(granted.to_f)} s is the most it grants"
|
|
123
|
+
end
|
|
124
|
+
self
|
|
67
125
|
end
|
|
68
126
|
|
|
69
|
-
# The
|
|
127
|
+
# The kind of model the service recognised (+LP+/+MILP+/+QUBO+/…), or +nil+
|
|
70
128
|
# if it did not report one.
|
|
71
129
|
#
|
|
72
130
|
# @return [String, nil]
|
|
@@ -125,7 +183,7 @@ module Quicopt
|
|
|
125
183
|
"DELETE" => Net::HTTP::Delete
|
|
126
184
|
}.freeze
|
|
127
185
|
|
|
128
|
-
# The content type of a request body:
|
|
186
|
+
# The content type of a request body: an encoded model is opaque binary.
|
|
129
187
|
OCTET_STREAM = "application/octet-stream"
|
|
130
188
|
|
|
131
189
|
# @return [String] the service base URL, without a trailing slash
|
|
@@ -165,19 +223,34 @@ module Quicopt
|
|
|
165
223
|
# Solve +model+ synchronously, blocking until the result returns.
|
|
166
224
|
#
|
|
167
225
|
# @param model [Quicopt::Model, Quicopt::Wire::PB::Program, String] a model —
|
|
168
|
-
#
|
|
169
|
-
#
|
|
226
|
+
# encoded here — or a +Program+ / already-encoded bytes if you built them
|
|
227
|
+
# yourself
|
|
170
228
|
# @param project [String, nil] an optional project tag, so calls on one key
|
|
171
229
|
# can be invoiced per project. Sent as a query param, never baked into the
|
|
172
230
|
# model.
|
|
231
|
+
# @param time_limit [Numeric, nil] how many seconds the solve may run, in
|
|
232
|
+
# 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.
|
|
173
237
|
# @param config [Hash, nil] optional service parameters, sent as the query
|
|
174
238
|
# string
|
|
175
239
|
# @param gzip [Boolean] gzip-compress the request body
|
|
176
240
|
# @return [Result]
|
|
177
241
|
# @raise [QuicoptError] on a non-2xx response
|
|
178
|
-
|
|
242
|
+
# @raise [TypeError] if +time_limit+ is not a number
|
|
243
|
+
# @raise [ArgumentError] if +time_limit+ is not positive and finite
|
|
244
|
+
#
|
|
245
|
+
# @example Give a search half a minute
|
|
246
|
+
# result = client.solve(m, time_limit: 30)
|
|
247
|
+
# result.solver_data["time_budget_s"] # => 30.0
|
|
248
|
+
def solve(model, project: nil, time_limit: nil, config: nil, gzip: false)
|
|
249
|
+
time_limit = Client.check_time_limit(time_limit)
|
|
250
|
+
wait = time_limit.nil? ? @timeout : [@timeout, time_limit + WAIT_MARGIN].max
|
|
179
251
|
Result.from_json(request("POST", "/v1/solve", to_wire(model),
|
|
180
|
-
config: meta_config(model, project, config),
|
|
252
|
+
config: meta_config(model, project, time_limit, config),
|
|
253
|
+
gzip: gzip, timeout: wait)).warn_if_lowered
|
|
181
254
|
end
|
|
182
255
|
|
|
183
256
|
# Submit +model+ for asynchronous solving and return a handle to poll.
|
|
@@ -185,13 +258,39 @@ module Quicopt
|
|
|
185
258
|
# Use this for the first call against a freshly-booted server: the worker
|
|
186
259
|
# warms up before claiming, which can time out a cold synchronous call.
|
|
187
260
|
#
|
|
261
|
+
# +time_limit+ works as for +solve+; +Job#result+ then polls at least that
|
|
262
|
+
# long plus a minute by default.
|
|
263
|
+
#
|
|
188
264
|
# @return [Job] a handle to the queued job; call +Job#result+ to await it
|
|
189
265
|
# @raise [QuicoptError] on a non-2xx response
|
|
190
|
-
|
|
266
|
+
# @raise [TypeError] if +time_limit+ is not a number
|
|
267
|
+
# @raise [ArgumentError] if +time_limit+ is not positive and finite
|
|
268
|
+
def submit(model, project: nil, time_limit: nil, config: nil, gzip: false)
|
|
269
|
+
time_limit = Client.check_time_limit(time_limit)
|
|
191
270
|
body = request("POST", "/v1/jobs", to_wire(model),
|
|
192
|
-
config: meta_config(model, project, config), gzip: gzip)
|
|
271
|
+
config: meta_config(model, project, time_limit, config), gzip: gzip)
|
|
193
272
|
remember(body["api_key"]) # /v1/jobs echoes a minted key in the 202 body too
|
|
194
|
-
Job.new(self, body["job_id"])
|
|
273
|
+
Job.new(self, body["job_id"], time_limit)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Check a time limit before it is sent.
|
|
277
|
+
#
|
|
278
|
+
# @param time_limit [Numeric, nil] seconds, or +nil+ to leave the choice to
|
|
279
|
+
# the service
|
|
280
|
+
# @return [Float, nil]
|
|
281
|
+
# @raise [TypeError] if it is not a number
|
|
282
|
+
# @raise [ArgumentError] if it is not positive and finite
|
|
283
|
+
# @api private
|
|
284
|
+
def self.check_time_limit(time_limit)
|
|
285
|
+
return nil if time_limit.nil?
|
|
286
|
+
raise TypeError, "time_limit is a number of seconds, got #{time_limit.inspect}" unless time_limit.is_a?(Numeric)
|
|
287
|
+
|
|
288
|
+
seconds = time_limit.to_f
|
|
289
|
+
unless seconds.finite? && seconds.positive?
|
|
290
|
+
raise ArgumentError, "time_limit is a positive, finite number of seconds, got #{time_limit}"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
seconds
|
|
195
294
|
end
|
|
196
295
|
|
|
197
296
|
# Perform one HTTP request, retrying once if a *cached* key is rejected.
|
|
@@ -209,13 +308,13 @@ module Quicopt
|
|
|
209
308
|
# @return [String] the raw response body
|
|
210
309
|
# @raise [QuicoptError] on a non-2xx response
|
|
211
310
|
# @api private
|
|
212
|
-
def request_raw(method, path, data = nil, config: nil, gzip: false)
|
|
213
|
-
attempt(method, path, data, config: config, gzip: gzip)
|
|
311
|
+
def request_raw(method, path, data = nil, config: nil, gzip: false, timeout: nil)
|
|
312
|
+
attempt(method, path, data, config: config, gzip: gzip, timeout: timeout)
|
|
214
313
|
rescue QuicoptError => e
|
|
215
314
|
raise unless e.status_code == 401 && @from_cache
|
|
216
315
|
|
|
217
316
|
discard_key
|
|
218
|
-
attempt(method, path, data, config: config, gzip: gzip)
|
|
317
|
+
attempt(method, path, data, config: config, gzip: gzip, timeout: timeout)
|
|
219
318
|
end
|
|
220
319
|
|
|
221
320
|
# Perform one HTTP request and return the raw response body.
|
|
@@ -223,12 +322,13 @@ module Quicopt
|
|
|
223
322
|
# Sets the octet-stream content type (and gzip encoding, if requested) for a
|
|
224
323
|
# body, attaches the bearer key when known, and captures a freshly minted key
|
|
225
324
|
# off the response — including from an error response, since a 502/504 on a
|
|
226
|
-
# first call may still have minted one.
|
|
325
|
+
# first call may still have minted one. +timeout+ replaces the client's socket
|
|
326
|
+
# timeout for this one request.
|
|
227
327
|
#
|
|
228
328
|
# @return [String] the raw response body
|
|
229
329
|
# @raise [QuicoptError] on a non-2xx response
|
|
230
330
|
# @api private
|
|
231
|
-
def attempt(method, path, data = nil, config: nil, gzip: false)
|
|
331
|
+
def attempt(method, path, data = nil, config: nil, gzip: false, timeout: nil)
|
|
232
332
|
uri = URI.parse(@base_url + path + Client.query(config))
|
|
233
333
|
request_class = REQUESTS.fetch(method) { raise ArgumentError, "unsupported method #{method}" }
|
|
234
334
|
req = request_class.new(uri)
|
|
@@ -242,7 +342,7 @@ module Quicopt
|
|
|
242
342
|
end
|
|
243
343
|
req["Authorization"] = "Bearer #{@api_key}" if @api_key
|
|
244
344
|
|
|
245
|
-
resp = http(uri).start { |conn| conn.request(req) }
|
|
345
|
+
resp = http(uri, timeout || @timeout).start { |conn| conn.request(req) }
|
|
246
346
|
capture_key(resp) # a 502/504 on a first call still minted a key
|
|
247
347
|
raise QuicoptError.new(resp.code.to_i, Client.decode(resp.body)) unless resp.is_a?(Net::HTTPSuccess)
|
|
248
348
|
|
|
@@ -359,43 +459,45 @@ module Quicopt
|
|
|
359
459
|
|
|
360
460
|
private
|
|
361
461
|
|
|
362
|
-
def http(uri)
|
|
462
|
+
def http(uri, timeout)
|
|
363
463
|
conn = Net::HTTP.new(uri.host, uri.port)
|
|
364
464
|
conn.use_ssl = uri.scheme == "https"
|
|
365
|
-
conn.open_timeout =
|
|
366
|
-
conn.read_timeout =
|
|
367
|
-
conn.write_timeout =
|
|
465
|
+
conn.open_timeout = timeout
|
|
466
|
+
conn.read_timeout = timeout
|
|
467
|
+
conn.write_timeout = timeout
|
|
368
468
|
conn
|
|
369
469
|
end
|
|
370
470
|
|
|
371
|
-
# Coerce to
|
|
372
|
-
#
|
|
373
|
-
#
|
|
471
|
+
# Coerce to encoded bytes: bytes pass through, a +Program+ is encoded, and a
|
|
472
|
+
# +Model+ becomes a +Program+ and is encoded — so a caller solves the *model*,
|
|
473
|
+
# never a hand-built +Program+.
|
|
374
474
|
def to_wire(model)
|
|
375
475
|
case model
|
|
376
476
|
when Model, Wire::PB::Program then Wire.encode(model)
|
|
377
477
|
when String then model
|
|
378
478
|
else
|
|
379
|
-
raise TypeError,
|
|
479
|
+
raise TypeError,
|
|
480
|
+
"cannot solve a #{model.class}: pass a Quicopt::Model, a Program, or encoded bytes"
|
|
380
481
|
end
|
|
381
482
|
end
|
|
382
483
|
|
|
383
|
-
# The modelling
|
|
384
|
-
# +Program+ /
|
|
484
|
+
# The modelling library that wrote +model+, or +nil+ for a hand-built
|
|
485
|
+
# +Program+ / raw bytes, which have none to attribute.
|
|
385
486
|
def source_language(model)
|
|
386
487
|
SOURCE_LANGUAGE if model.is_a?(Model)
|
|
387
488
|
end
|
|
388
489
|
|
|
389
|
-
# Merge the per-call
|
|
390
|
-
#
|
|
391
|
-
# optional
|
|
392
|
-
# not the
|
|
393
|
-
def meta_config(model, project, config)
|
|
490
|
+
# Merge the per-call tags into the request config (sent as query params): the
|
|
491
|
+
# modelling-library tag, unless the caller already set one, the optional
|
|
492
|
+
# project id and the optional time limit. The model is unchanged — these ride
|
|
493
|
+
# the query string, not the encoded model.
|
|
494
|
+
def meta_config(model, project, time_limit, config)
|
|
394
495
|
meta = {}
|
|
395
496
|
(config || {}).each { |k, v| meta[k.to_s] = v }
|
|
396
497
|
src = source_language(model)
|
|
397
498
|
meta["source_language"] = src if src && !meta.key?("source_language")
|
|
398
499
|
meta["project_id"] = project if project
|
|
500
|
+
meta["time_limit"] = time_limit if time_limit
|
|
399
501
|
meta
|
|
400
502
|
end
|
|
401
503
|
|
|
@@ -445,10 +547,13 @@ module Quicopt
|
|
|
445
547
|
attr_reader :client
|
|
446
548
|
# @return [String] the server-assigned job id
|
|
447
549
|
attr_reader :job_id
|
|
550
|
+
# @return [Float, nil] the time limit the job was submitted with, if any
|
|
551
|
+
attr_reader :time_limit
|
|
448
552
|
|
|
449
|
-
def initialize(client, job_id)
|
|
553
|
+
def initialize(client, job_id, time_limit = nil)
|
|
450
554
|
@client = client
|
|
451
555
|
@job_id = job_id
|
|
556
|
+
@time_limit = time_limit
|
|
452
557
|
end
|
|
453
558
|
|
|
454
559
|
# Fetch the job's metadata and framed log tail.
|
|
@@ -463,15 +568,17 @@ module Quicopt
|
|
|
463
568
|
#
|
|
464
569
|
# @param wait [Boolean] poll past the service's +not_done+ reason until the
|
|
465
570
|
# worker finishes; when +false+, fetch once
|
|
466
|
-
# @param timeout [Numeric] maximum time to poll, in seconds
|
|
571
|
+
# @param timeout [Numeric, nil] maximum time to poll, in seconds. Defaults to
|
|
572
|
+
# 120 s, or the job's +time_limit+ plus a minute when that is longer.
|
|
467
573
|
# @param poll [Numeric] delay between polls, in seconds
|
|
468
574
|
# @return [Result]
|
|
469
575
|
# @raise [QuicoptError] if +wait+ is false and the job is not yet done, on any
|
|
470
576
|
# non-+not_done+ error, or once +timeout+ elapses
|
|
471
|
-
def result(wait: true, timeout:
|
|
577
|
+
def result(wait: true, timeout: nil, poll: 0.5)
|
|
578
|
+
timeout ||= @time_limit.nil? ? 120.0 : [120.0, @time_limit + WAIT_MARGIN].max
|
|
472
579
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
473
580
|
loop do
|
|
474
|
-
return Result.from_json(@client.request("GET", "/v1/jobs/#{@job_id}/result"))
|
|
581
|
+
return Result.from_json(@client.request("GET", "/v1/jobs/#{@job_id}/result")).warn_if_lowered
|
|
475
582
|
rescue QuicoptError => e
|
|
476
583
|
raise if !wait || e.reason != "not_done"
|
|
477
584
|
raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# SPDX-FileCopyrightText: (c) 2026 Tim Bode, PGI-12, Forschungszentrum Jülich
|
|
3
|
+
# frozen_string_literal: true
|
|
4
|
+
|
|
5
|
+
# quicopt/indicators — terms that switch: whether a variable is in use
|
|
6
|
+
# (+Variable#active+), whether a comparison holds (+Quicopt.holds+), and the
|
|
7
|
+
# smaller or larger of several expressions (+Quicopt.min+, +Quicopt.max+).
|
|
8
|
+
#
|
|
9
|
+
# None of them is a polynomial, and each enters an +Expression+ the way a read of
|
|
10
|
+
# a permutation does (+quicopt/structured+): as one factor of degree 1, a key of
|
|
11
|
+
# the expression's linear terms. So +fee * x.active+ is linear, +q * x.active+
|
|
12
|
+
# quadratic, and the degree check stays exact.
|
|
13
|
+
#
|
|
14
|
+
# Factors are interned per model — asking twice for the same term returns the
|
|
15
|
+
# same object — so +x.active - x.active+ cancels and two equal terms meet on one
|
|
16
|
+
# coefficient. A comparison is mapped to its 0/1 form once, in +Quicopt.holds+,
|
|
17
|
+
# the same mapping as the R client's +holds()+:
|
|
18
|
+
#
|
|
19
|
+
# a <= b ⇒ step(b − a + tol) a >= b ⇒ step(a − b + tol)
|
|
20
|
+
# a < b ⇒ 1 − step(a − b − tol) a > b ⇒ 1 − step(b − a − tol)
|
|
21
|
+
# a == b ⇒ 1 − indicator(a − b) a != b ⇒ indicator(a − b)
|
|
22
|
+
#
|
|
23
|
+
# where +step(e)+ is 1 when +e ≥ 0+ and +indicator(e)+ is 1 when +e ≠ 0+; with a
|
|
24
|
+
# tolerance, +==+ and +!=+ compare +|a − b|+ against it instead.
|
|
25
|
+
|
|
26
|
+
module Quicopt
|
|
27
|
+
class << self
|
|
28
|
+
# 1 where the comparison holds and 0 where it does not, as an expression the
|
|
29
|
+
# service evaluates at the values it chooses. Its main use is a cost or a
|
|
30
|
+
# discount that applies once a total crosses a level: a warehouse's opening
|
|
31
|
+
# fee, due once it ships anything, is
|
|
32
|
+
#
|
|
33
|
+
# opening_fee * Quicopt.holds(shipped >= 1)
|
|
34
|
+
#
|
|
35
|
+
# and a discount of 30 from 60 units on is +-30 * Quicopt.holds(shipped >= 60)+.
|
|
36
|
+
#
|
|
37
|
+
# @param comparison [Constraint] built with +<=+, +>=+, +<+, +>+, +==+ or
|
|
38
|
+
# +!=+ between expressions, such as +shipped >= 60+
|
|
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.
|
|
42
|
+
# @return [Expression] a 0/1 expression; a number when the comparison
|
|
43
|
+
# involves no decision
|
|
44
|
+
# @raise [TypeError] if +comparison+ is not a comparison — often one Ruby
|
|
45
|
+
# already reduced to +true+ or +false+ because both sides were numbers
|
|
46
|
+
# @raise [ArgumentError] if +tol+ is negative, or the two sides belong to
|
|
47
|
+
# different models
|
|
48
|
+
def holds(comparison, tol: 0.0)
|
|
49
|
+
unless comparison.is_a?(Constraint)
|
|
50
|
+
raise TypeError, "holds takes a comparison such as `x >= 3`, got #{comparison.inspect}"
|
|
51
|
+
end
|
|
52
|
+
raise ArgumentError, "tol is a non-negative number, got #{tol.inspect}" unless tol.is_a?(Numeric) && tol >= 0
|
|
53
|
+
|
|
54
|
+
Conditions.holds(comparison.expr, comparison.sense, tol.to_f)
|
|
55
|
+
end
|
|
56
|
+
|
|
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.
|
|
60
|
+
#
|
|
61
|
+
# @param args [Array<Expression, Variable, Numeric>] at least two
|
|
62
|
+
# @return [Expression] a number when every argument is one
|
|
63
|
+
# @raise [ArgumentError] on fewer than two arguments, or arguments from
|
|
64
|
+
# different models
|
|
65
|
+
def min(*args)
|
|
66
|
+
Extremum.build(:min, args)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The largest of the given expressions, as an expression the service
|
|
70
|
+
# evaluates.
|
|
71
|
+
#
|
|
72
|
+
# @param args [Array<Expression, Variable, Numeric>] at least two
|
|
73
|
+
# @return [Expression] a number when every argument is one
|
|
74
|
+
# @raise [ArgumentError] on fewer than two arguments, or arguments from
|
|
75
|
+
# different models
|
|
76
|
+
def max(*args)
|
|
77
|
+
Extremum.build(:max, args)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The mapping from a comparison to its 0/1 form, and the model a term belongs
|
|
82
|
+
# to.
|
|
83
|
+
#
|
|
84
|
+
# @api private
|
|
85
|
+
module Conditions
|
|
86
|
+
module_function
|
|
87
|
+
|
|
88
|
+
# +residual+ is +lhs − rhs+; the result follows the table in this file's
|
|
89
|
+
# header.
|
|
90
|
+
def holds(residual, sense, tol)
|
|
91
|
+
return Expression.new(constant: truth(residual.constant, sense, tol) ? 1.0 : 0.0) if residual.constant?
|
|
92
|
+
|
|
93
|
+
negated = residual.scale(-1.0)
|
|
94
|
+
case sense
|
|
95
|
+
when :le then term(:step, negated.plus(Expression.new(constant: tol)))
|
|
96
|
+
when :ge then term(:step, residual.plus(Expression.new(constant: tol)))
|
|
97
|
+
when :lt then one_minus(term(:step, residual.plus(Expression.new(constant: -tol))))
|
|
98
|
+
when :gt then one_minus(term(:step, negated.plus(Expression.new(constant: -tol))))
|
|
99
|
+
when :eq then tol.positive? ? term(:near, residual, tol) : one_minus(term(:indicator, residual))
|
|
100
|
+
when :ne then tol.positive? ? one_minus(term(:near, residual, tol)) : term(:indicator, residual)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Whether a comparison between two numbers (+value = lhs − rhs+) holds.
|
|
105
|
+
def truth(value, sense, tol)
|
|
106
|
+
case sense
|
|
107
|
+
when :le then -value + tol >= 0
|
|
108
|
+
when :ge then value + tol >= 0
|
|
109
|
+
when :lt then (value - tol).negative?
|
|
110
|
+
when :gt then (-value - tol).negative?
|
|
111
|
+
when :eq then tol.positive? ? value.abs <= tol : value.zero?
|
|
112
|
+
when :ne then tol.positive? ? value.abs > tol : !value.zero?
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The interned +Condition+ +op+ over +argument+, as an expression.
|
|
117
|
+
def term(op, argument, tol = 0.0)
|
|
118
|
+
model = owner(argument.factors)
|
|
119
|
+
key = [:condition, op, argument_key(argument), tol]
|
|
120
|
+
Expression.new(linear: { model.intern(key) { Condition.new(model, op, argument, tol, model.next_order) } => 1.0 })
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# +1 − e+.
|
|
124
|
+
def one_minus(expr)
|
|
125
|
+
Expression.new(constant: 1.0).plus(expr.scale(-1.0))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# The one model every factor in +factors+ belongs to.
|
|
129
|
+
#
|
|
130
|
+
# @raise [ArgumentError] if they belong to more than one
|
|
131
|
+
def owner(factors)
|
|
132
|
+
models = factors.map(&:model).uniq(&:object_id)
|
|
133
|
+
raise ArgumentError, "the terms of one expression belong to different Quicopt::Models" if models.size > 1
|
|
134
|
+
|
|
135
|
+
models.first
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# An expression as an interning key: its constant and its terms in model
|
|
139
|
+
# order. Factors are interned, so they key by identity.
|
|
140
|
+
def argument_key(expr)
|
|
141
|
+
[expr.constant, expr.linear.sort_by { |f, _| f.order }, expr.quadratic.sort_by { |(a, b), _| [a.order, b.order] }]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# A 0/1 term: +indicator(e)+ (1 when +e ≠ 0+), +step(e)+ (1 when +e ≥ 0+) or
|
|
146
|
+
# the near test +step(tol − |e|)+. One factor of an +Expression+.
|
|
147
|
+
#
|
|
148
|
+
# @api private
|
|
149
|
+
class Condition
|
|
150
|
+
# @return [Model]
|
|
151
|
+
attr_reader :model
|
|
152
|
+
# @return [Symbol] +:indicator+, +:step+ or +:near+
|
|
153
|
+
attr_reader :op
|
|
154
|
+
# @return [Expression] the expression tested
|
|
155
|
+
attr_reader :argument
|
|
156
|
+
# @return [Float] the tolerance of +:near+, 0 otherwise
|
|
157
|
+
attr_reader :tol
|
|
158
|
+
# @return [Array] where this term sorts among a model's terms
|
|
159
|
+
attr_reader :order
|
|
160
|
+
|
|
161
|
+
def initialize(model, op, argument, tol, order)
|
|
162
|
+
@model = model
|
|
163
|
+
@op = op
|
|
164
|
+
@argument = argument
|
|
165
|
+
@tol = tol
|
|
166
|
+
@order = order
|
|
167
|
+
freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# @return [Array<Variable>] the variables the tested expression mentions
|
|
171
|
+
def variables
|
|
172
|
+
@argument.variables
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @return [String] the term as sent, e.g. +step(x + y - 1)+; the indicator of
|
|
176
|
+
# a bare variable reads +x.active+
|
|
177
|
+
def to_s
|
|
178
|
+
bare = @argument.constant.zero? && @argument.quadratic.empty? && @argument.linear.size == 1 &&
|
|
179
|
+
@argument.linear.first[0].is_a?(Variable) && @argument.linear.first[1] == 1.0
|
|
180
|
+
case @op
|
|
181
|
+
when :indicator then bare ? "#{@argument}.active" : "indicator(#{@argument})"
|
|
182
|
+
when :step then "step(#{@argument})"
|
|
183
|
+
when :near then "step(#{Format.number(@tol)} - |#{@argument}|)"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# The smaller or the larger of several expressions. One factor of an
|
|
189
|
+
# +Expression+.
|
|
190
|
+
#
|
|
191
|
+
# @api private
|
|
192
|
+
class Extremum
|
|
193
|
+
# @return [Model]
|
|
194
|
+
attr_reader :model
|
|
195
|
+
# @return [Symbol] +:min+ or +:max+
|
|
196
|
+
attr_reader :op
|
|
197
|
+
# @return [Array<Expression>] the arguments, in the order given
|
|
198
|
+
attr_reader :args
|
|
199
|
+
# @return [Array] where this term sorts among a model's terms
|
|
200
|
+
attr_reader :order
|
|
201
|
+
|
|
202
|
+
# The interned +op+ over +args+ as an expression, or the number itself when
|
|
203
|
+
# every argument is one.
|
|
204
|
+
def self.build(op, args)
|
|
205
|
+
raise ArgumentError, "#{op} takes at least two arguments, got #{args.size}" if args.size < 2
|
|
206
|
+
|
|
207
|
+
exprs = args.map { |a| Expression.cast(a) }
|
|
208
|
+
if exprs.all?(&:constant?)
|
|
209
|
+
values = exprs.map(&:constant)
|
|
210
|
+
return Expression.new(constant: op == :min ? values.min : values.max)
|
|
211
|
+
end
|
|
212
|
+
model = Conditions.owner(exprs.flat_map(&:factors))
|
|
213
|
+
key = [:extremum, op, exprs.map { |e| Conditions.argument_key(e) }]
|
|
214
|
+
Expression.new(linear: { model.intern(key) { new(model, op, exprs.freeze, model.next_order) } => 1.0 })
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def initialize(model, op, args, order)
|
|
218
|
+
@model = model
|
|
219
|
+
@op = op
|
|
220
|
+
@args = args
|
|
221
|
+
@order = order
|
|
222
|
+
freeze
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# @return [Array<Variable>] the variables the arguments mention
|
|
226
|
+
def variables
|
|
227
|
+
@args.flat_map(&:variables).uniq
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @return [String] the term as written, e.g. +min(1, a.active + b.active)+
|
|
231
|
+
def to_s
|
|
232
|
+
"#{@op}(#{@args.join(', ')})"
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|