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.
@@ -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 to wire bytes (+Quicopt::Wire+), POST it, read the result back.
15
- # Transport is stdlib +net/http+, so the gem's only runtime dependency stays
16
- # +google-protobuf+. The request body is the versioned wire bytes; the response is
17
- # the service's result JSON (+status+ / +objective+ / +solution+ / a
18
- # ready-to-print +display+ / …). The first keyless call mints an API key, returned
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 front-end this client authors with, sent as the
38
- # +source_language+ call tag. It names the *front-end*, not the language — the
39
- # Ruby DSL is one front-end among the clients' several (+jump+, +pyomo+,
40
- # +pulp+, +mathopt+), so it carries its own name here.
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+ when the class or outcome leaves them
46
- # undefined (an unconstrained heuristic, or no incumbent). +display+ is the
47
- # framed, ready-to-print summary the service renders for every backend alike —
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 class the service routed the model to (+LP+/+MILP+/+QUBO+/…), or +nil+
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: the wire bytes are opaque binary.
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
- # lowered and encoded here — or a +Program+ / pre-encoded wire bytes if you
169
- # built them yourself
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
- def solve(model, project: nil, config: nil, gzip: false)
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), gzip: gzip))
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
- def submit(model, project: nil, config: nil, gzip: false)
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 = @timeout
366
- conn.read_timeout = @timeout
367
- conn.write_timeout = @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 wire bytes: pre-encoded bytes pass through, a +Program+ is
372
- # encoded, and a +Model+ is lowered then encoded — so a caller solves the
373
- # *model*, never a hand-built +Program+.
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, "cannot solve a #{model.class}: pass a Quicopt::Model, a Program, or wire bytes"
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 front-end that authored +model+, or +nil+ for a hand-built
384
- # +Program+ / pre-encoded bytes, which have no front-end to attribute.
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 metadata tags into the request config (sent as query
390
- # params): the front-end tag, unless the caller already set one, and the
391
- # optional project id. The model is unchanged — these ride the query string,
392
- # not the wire bytes.
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: 120.0, poll: 0.5)
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