quicopt 0.1.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.
@@ -0,0 +1,504 @@
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
+ require "json"
6
+ require "net/http"
7
+ require "pathname"
8
+ require "stringio"
9
+ require "uri"
10
+ require "zlib"
11
+
12
+ # quicopt/client — talk to the Quicopt service over HTTP.
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
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.
23
+ #
24
+ # Two entry points mirror the two service endpoints:
25
+ #
26
+ # - +Client#solve+ — POST +/v1/solve+, block for the result (synchronous).
27
+ # - +Client#submit+ — POST +/v1/jobs+, return a +Job+ to poll.
28
+ #
29
+ # A non-2xx response raises +QuicoptError+, carrying the service's stable +reason+
30
+ # code and the framed +display+ text.
31
+ module Quicopt
32
+ # The public Quicopt free-tier endpoint a +Client+ targets when no +base_url+
33
+ # is given. Mirrors the Python and Julia clients' default, so every client
34
+ # reaches the same server out of the box.
35
+ DEFAULT_BASE_URL = "https://try.quicoptapi.pgi.fz-juelich.de"
36
+
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.
41
+ SOURCE_LANGUAGE = "quicopt-ruby"
42
+
43
+ # A finished solve, parsed from the service's result JSON.
44
+ #
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.
49
+ Result = Data.define(:job_id, :status, :objective, :feasible, :solution,
50
+ :solve_time_seconds, :solver_data, :display) do
51
+ # Build a +Result+ from the service's decoded result JSON.
52
+ #
53
+ # Missing optional keys default rather than raise, so a partial result (e.g.
54
+ # a heuristic backend that reports no objective) still parses.
55
+ #
56
+ # @param body [Hash] the decoded result JSON object
57
+ # @return [Result]
58
+ def self.from_json(body)
59
+ new(job_id: body["job_id"].to_s,
60
+ status: body["status"].to_s,
61
+ objective: body["objective"],
62
+ feasible: body["feasible"],
63
+ solution: body["solution"] || {},
64
+ solve_time_seconds: body["solve_time_seconds"] || 0.0,
65
+ solver_data: body["solver_data"] || {},
66
+ display: body["display"].to_s)
67
+ end
68
+
69
+ # The class the service routed the model to (+LP+/+MILP+/+QUBO+/…), or +nil+
70
+ # if it did not report one.
71
+ #
72
+ # @return [String, nil]
73
+ def model_class
74
+ solver_data["model_class"]
75
+ end
76
+ end
77
+
78
+ # A non-2xx service response.
79
+ #
80
+ # +reason+ is the service's stable snake_case code (+size_exceeded+,
81
+ # +unsupported_model+, +quota_exhausted+, +not_done+, +too_many_inflight+,
82
+ # +queue_full+, +parse_error+, …) — match on it, not on the message. +display+
83
+ # is the framed text to print; +status_code+ the HTTP status.
84
+ class QuicoptError < Error
85
+ # @return [Integer] the HTTP status code
86
+ attr_reader :status_code
87
+ # @return [Hash] the decoded error body
88
+ attr_reader :body
89
+ # @return [String, nil] the stable machine-readable reason code
90
+ attr_reader :reason
91
+ # @return [String, nil] the server-rendered, ready-to-print message
92
+ attr_reader :display
93
+
94
+ def initialize(status_code, body)
95
+ @status_code = status_code
96
+ @body = body.is_a?(Hash) ? body : { "error" => body.to_s }
97
+ @reason = @body["reason"]
98
+ @display = @body["display"]
99
+ super(@body["error"] || "HTTP #{status_code}")
100
+ end
101
+ end
102
+
103
+ # Environment variable overriding where the free key is cached. Point it at
104
+ # durable storage in an environment whose home directory does not survive the
105
+ # run (CI, containers), where the default location is wiped between sessions
106
+ # and every run would otherwise mint a fresh key.
107
+ KEY_PATH_ENV = "QUICOPT_KEY_PATH"
108
+
109
+ # A connection to a Quicopt service at +base_url+ — the public free tier
110
+ # (+DEFAULT_BASE_URL+) unless another URL is given.
111
+ #
112
+ # The client holds the API key: pass a known one, or let the first keyless call
113
+ # mint one — which is then cached on disk and reused by later runs, so one
114
+ # caller keeps one key.
115
+ #
116
+ # client = Quicopt::Client.new
117
+ # result = client.solve(model)
118
+ # puts result.display
119
+ # client.api_key # => the key the first call minted (and cached)
120
+ class Client
121
+ # The HTTP methods this client issues, and the request class for each.
122
+ REQUESTS = {
123
+ "GET" => Net::HTTP::Get,
124
+ "POST" => Net::HTTP::Post,
125
+ "DELETE" => Net::HTTP::Delete
126
+ }.freeze
127
+
128
+ # The content type of a request body: the wire bytes are opaque binary.
129
+ OCTET_STREAM = "application/octet-stream"
130
+
131
+ # @return [String] the service base URL, without a trailing slash
132
+ attr_reader :base_url
133
+ # @return [String, nil] the API key in use; set on the first mint
134
+ attr_accessor :api_key
135
+ # @return [Float] the per-request socket timeout, in seconds
136
+ attr_accessor :timeout
137
+ # @return [Pathname] where the free key is cached
138
+ attr_reader :key_path
139
+
140
+ # Bind a client to a service endpoint.
141
+ #
142
+ # @param base_url [String] the service base URL; a trailing slash is stripped
143
+ # @param api_key [String, nil] a known API key, or +nil+ to reuse the cached
144
+ # free key (minting one on the first keyless call). A key passed here is
145
+ # used as-is and never written to the cache, so authenticating with a key
146
+ # you already hold cannot clobber the free key of whoever runs the code.
147
+ # @param timeout [Numeric] the per-request socket timeout, in seconds
148
+ # @param key_path [String, Pathname, nil] where to cache the free key;
149
+ # defaults to +Client.default_key_path+
150
+ # @param cache [Boolean] +false+ keeps the key in memory only, neither
151
+ # reading nor writing the cache file
152
+ def initialize(base_url = DEFAULT_BASE_URL, api_key = nil, timeout: 60.0,
153
+ key_path: nil, cache: true)
154
+ @base_url = base_url.to_s.sub(%r{/+\z}, "")
155
+ @timeout = timeout
156
+ @key_path = Pathname(key_path || Client.default_key_path).expand_path
157
+ @cache = cache
158
+ @explicit = !api_key.nil?
159
+ @api_key = @explicit ? api_key : (cache ? Client.read_key(@key_path) : nil)
160
+ # Only a key that came off disk may be discarded and re-minted on a 401 —
161
+ # see +request_raw+, which relies on this to bound re-minting to one per run.
162
+ @from_cache = !@api_key.nil? && !@explicit
163
+ end
164
+
165
+ # Solve +model+ synchronously, blocking until the result returns.
166
+ #
167
+ # @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
170
+ # @param project [String, nil] an optional project tag, so calls on one key
171
+ # can be invoiced per project. Sent as a query param, never baked into the
172
+ # model.
173
+ # @param config [Hash, nil] optional service parameters, sent as the query
174
+ # string
175
+ # @param gzip [Boolean] gzip-compress the request body
176
+ # @return [Result]
177
+ # @raise [QuicoptError] on a non-2xx response
178
+ def solve(model, project: nil, config: nil, gzip: false)
179
+ Result.from_json(request("POST", "/v1/solve", to_wire(model),
180
+ config: meta_config(model, project, config), gzip: gzip))
181
+ end
182
+
183
+ # Submit +model+ for asynchronous solving and return a handle to poll.
184
+ #
185
+ # Use this for the first call against a freshly-booted server: the worker
186
+ # warms up before claiming, which can time out a cold synchronous call.
187
+ #
188
+ # @return [Job] a handle to the queued job; call +Job#result+ to await it
189
+ # @raise [QuicoptError] on a non-2xx response
190
+ def submit(model, project: nil, config: nil, gzip: false)
191
+ body = request("POST", "/v1/jobs", to_wire(model),
192
+ config: meta_config(model, project, config), gzip: gzip)
193
+ remember(body["api_key"]) # /v1/jobs echoes a minted key in the 202 body too
194
+ Job.new(self, body["job_id"])
195
+ end
196
+
197
+ # Perform one HTTP request, retrying once if a *cached* key is rejected.
198
+ #
199
+ # A cache file can outlive the key it holds (the server was reset, the key
200
+ # was revoked, the file was copied from another machine), and a stale key
201
+ # 401s every call forever. So a 401 discards the cached key and retries
202
+ # once, keyless — which mints a fresh key and caches it.
203
+ #
204
+ # The retry is deliberately narrow: only a key read from disk is discarded,
205
+ # never one minted in this process and never a caller-supplied +api_key+. A
206
+ # run can therefore mint at most one key beyond the stale one, so a server
207
+ # that rejected a key it just issued can never turn into a mint loop.
208
+ #
209
+ # @return [String] the raw response body
210
+ # @raise [QuicoptError] on a non-2xx response
211
+ # @api private
212
+ def request_raw(method, path, data = nil, config: nil, gzip: false)
213
+ attempt(method, path, data, config: config, gzip: gzip)
214
+ rescue QuicoptError => e
215
+ raise unless e.status_code == 401 && @from_cache
216
+
217
+ discard_key
218
+ attempt(method, path, data, config: config, gzip: gzip)
219
+ end
220
+
221
+ # Perform one HTTP request and return the raw response body.
222
+ #
223
+ # Sets the octet-stream content type (and gzip encoding, if requested) for a
224
+ # body, attaches the bearer key when known, and captures a freshly minted key
225
+ # off the response — including from an error response, since a 502/504 on a
226
+ # first call may still have minted one.
227
+ #
228
+ # @return [String] the raw response body
229
+ # @raise [QuicoptError] on a non-2xx response
230
+ # @api private
231
+ def attempt(method, path, data = nil, config: nil, gzip: false)
232
+ uri = URI.parse(@base_url + path + Client.query(config))
233
+ request_class = REQUESTS.fetch(method) { raise ArgumentError, "unsupported method #{method}" }
234
+ req = request_class.new(uri)
235
+ if data
236
+ req["Content-Type"] = OCTET_STREAM
237
+ if gzip
238
+ data = Client.gzip(data)
239
+ req["Content-Encoding"] = "gzip"
240
+ end
241
+ req.body = data
242
+ end
243
+ req["Authorization"] = "Bearer #{@api_key}" if @api_key
244
+
245
+ resp = http(uri).start { |conn| conn.request(req) }
246
+ capture_key(resp) # a 502/504 on a first call still minted a key
247
+ raise QuicoptError.new(resp.code.to_i, Client.decode(resp.body)) unless resp.is_a?(Net::HTTPSuccess)
248
+
249
+ resp.body.to_s
250
+ end
251
+
252
+ # Perform a request via +request_raw+ and parse its JSON body.
253
+ #
254
+ # @return [Hash] the parsed JSON object, or +{}+ for an empty body
255
+ # @raise [QuicoptError] on a non-2xx response
256
+ # @api private
257
+ def request(method, path, data = nil, **options)
258
+ raw = request_raw(method, path, data, **options)
259
+ raw.empty? ? {} : JSON.parse(raw)
260
+ end
261
+
262
+ # Render a config mapping as a URL query-string suffix.
263
+ #
264
+ # Escapes per RFC 3986, so a space becomes +%20+ and never +'+'+ — the form
265
+ # the service's query parser round-trips unambiguously, and the form the
266
+ # other clients send. Ruby's stock helpers (+URI.encode_www_form+,
267
+ # +CGI.escape+, +URI.encode_www_form_component+) all emit +'+'+ here, so none
268
+ # of them can be used.
269
+ #
270
+ # @param config [Hash, nil]
271
+ # @return [String] +"?k=v&…"+ if +config+ is non-empty, else +""+
272
+ # @api private
273
+ def self.query(config)
274
+ return "" if config.nil? || config.empty?
275
+
276
+ "?" + config.map { |k, v| "#{escape(k)}=#{escape(v)}" }.join("&")
277
+ end
278
+
279
+ # Percent-encode one query component per RFC 3986 (unreserved characters
280
+ # pass through; everything else is escaped byte-wise, UTF-8 included).
281
+ #
282
+ # @api private
283
+ def self.escape(value)
284
+ value.to_s.b.gsub(/[^A-Za-z0-9\-._~]/) { |c| format("%%%02X", c.ord) }
285
+ end
286
+
287
+ # Gzip-compress a request body.
288
+ #
289
+ # @api private
290
+ def self.gzip(data)
291
+ io = StringIO.new(+"".b)
292
+ writer = Zlib::GzipWriter.new(io)
293
+ begin
294
+ writer.write(data)
295
+ ensure
296
+ writer.close
297
+ end
298
+ io.string
299
+ end
300
+
301
+ # Best-effort decode of a response body into a Hash.
302
+ #
303
+ # @return [Hash] the parsed JSON if it parses (or +{}+ when empty), else the
304
+ # body wrapped as +{"error" => text}+ so a non-JSON error still surfaces
305
+ # @api private
306
+ def self.decode(raw)
307
+ text = raw.to_s
308
+ return {} if text.empty?
309
+
310
+ parsed = JSON.parse(text)
311
+ parsed.is_a?(Hash) ? parsed : { "error" => text }
312
+ rescue JSON::ParserError
313
+ { "error" => text }
314
+ end
315
+
316
+ # The default location of the cached free key:
317
+ # +$XDG_CACHE_HOME/quicopt/free_key+, falling back to +~/.cache+ when the
318
+ # variable is unset, unless +KEY_PATH_ENV+ overrides the whole path.
319
+ #
320
+ # @return [Pathname]
321
+ def self.default_key_path
322
+ override = ENV[KEY_PATH_ENV]
323
+ return Pathname(override) unless override.nil? || override.empty?
324
+
325
+ root = ENV["XDG_CACHE_HOME"]
326
+ root = File.join(Dir.home, ".cache") if root.nil? || root.empty?
327
+ Pathname(root) + "quicopt" + "free_key"
328
+ end
329
+
330
+ # Read the cached key. A missing or unreadable cache is not an error — it
331
+ # just means this caller has no key yet and the next call mints one.
332
+ #
333
+ # @return [String, nil] the cached key, or +nil+ if absent/unreadable/empty
334
+ def self.read_key(path)
335
+ key = path.read.strip
336
+ key.empty? ? nil : key
337
+ rescue SystemCallError, IOError
338
+ nil
339
+ end
340
+
341
+ # Persist +key+ at +path+, atomically and readable only by its owner.
342
+ #
343
+ # The key is a credential, so the file is +0600+; the write goes to a temp
344
+ # file in the same directory and is then renamed into place, so a crash or a
345
+ # concurrent writer can never leave a truncated key behind for the next run
346
+ # to send.
347
+ #
348
+ # @raise [SystemCallError] if the directory or file cannot be written
349
+ # (callers treat caching as best-effort and degrade to a warning)
350
+ def self.write_key(path, key)
351
+ path.dirname.mkpath
352
+ path.dirname.chmod(0o700)
353
+ tmp = path.dirname + ".free_key.#{Process.pid}"
354
+ tmp.open(File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |fh| fh.write(key) }
355
+ tmp.rename(path.to_s)
356
+ ensure
357
+ tmp&.unlink if tmp&.exist?
358
+ end
359
+
360
+ private
361
+
362
+ def http(uri)
363
+ conn = Net::HTTP.new(uri.host, uri.port)
364
+ conn.use_ssl = uri.scheme == "https"
365
+ conn.open_timeout = @timeout
366
+ conn.read_timeout = @timeout
367
+ conn.write_timeout = @timeout
368
+ conn
369
+ end
370
+
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+.
374
+ def to_wire(model)
375
+ case model
376
+ when Model, Wire::PB::Program then Wire.encode(model)
377
+ when String then model
378
+ else
379
+ raise TypeError, "cannot solve a #{model.class}: pass a Quicopt::Model, a Program, or wire bytes"
380
+ end
381
+ end
382
+
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.
385
+ def source_language(model)
386
+ SOURCE_LANGUAGE if model.is_a?(Model)
387
+ end
388
+
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)
394
+ meta = {}
395
+ (config || {}).each { |k, v| meta[k.to_s] = v }
396
+ src = source_language(model)
397
+ meta["source_language"] = src if src && !meta.key?("source_language")
398
+ meta["project_id"] = project if project
399
+ meta
400
+ end
401
+
402
+ # Remember a freshly minted API key from a response, if we have none yet. A
403
+ # key we already hold is never overwritten.
404
+ def capture_key(resp)
405
+ remember(resp["X-Quicopt-Api-Key"])
406
+ end
407
+
408
+ # Adopt +minted+ as this client's key and cache it for the next run.
409
+ #
410
+ # Persisting is best-effort: an unwritable cache (read-only home, a container
411
+ # without +$HOME+) warns rather than raising, since the solve itself
412
+ # succeeded and failing it over a cache miss would be worse than re-minting.
413
+ def remember(minted)
414
+ return if minted.nil? || minted.empty? || !(@api_key.nil? || @api_key.empty?)
415
+
416
+ @api_key = minted
417
+ return if @explicit || !@cache
418
+
419
+ begin
420
+ Client.write_key(@key_path, minted)
421
+ rescue SystemCallError, IOError => e
422
+ warn "quicopt: could not cache the free key at #{@key_path} (#{e.message}); " \
423
+ "every run will mint a new key — set $#{KEY_PATH_ENV} to a writable location"
424
+ end
425
+ end
426
+
427
+ # Drop the cached key the server just rejected, in memory and on disk. The
428
+ # next request goes out keyless and mints a replacement.
429
+ def discard_key
430
+ @api_key = nil
431
+ @from_cache = false
432
+ return unless @cache
433
+
434
+ begin
435
+ @key_path.unlink
436
+ rescue SystemCallError, IOError
437
+ nil # an unremovable cache still gets re-minted past
438
+ end
439
+ end
440
+ end
441
+
442
+ # A handle to an asynchronous job. +result+ polls until it finishes.
443
+ class Job
444
+ # @return [Client] the client that submitted the job
445
+ attr_reader :client
446
+ # @return [String] the server-assigned job id
447
+ attr_reader :job_id
448
+
449
+ def initialize(client, job_id)
450
+ @client = client
451
+ @job_id = job_id
452
+ end
453
+
454
+ # Fetch the job's metadata and framed log tail.
455
+ #
456
+ # @return [Hash] the job state (+queued+/+running+/+done+/+failed+) and its
457
+ # log tail, as returned by the service
458
+ def status
459
+ @client.request("GET", "/v1/jobs/#{@job_id}")
460
+ end
461
+
462
+ # Fetch the job's result, optionally polling until it is ready.
463
+ #
464
+ # @param wait [Boolean] poll past the service's +not_done+ reason until the
465
+ # worker finishes; when +false+, fetch once
466
+ # @param timeout [Numeric] maximum time to poll, in seconds
467
+ # @param poll [Numeric] delay between polls, in seconds
468
+ # @return [Result]
469
+ # @raise [QuicoptError] if +wait+ is false and the job is not yet done, on any
470
+ # non-+not_done+ error, or once +timeout+ elapses
471
+ def result(wait: true, timeout: 120.0, poll: 0.5)
472
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
473
+ loop do
474
+ return Result.from_json(@client.request("GET", "/v1/jobs/#{@job_id}/result"))
475
+ rescue QuicoptError => e
476
+ raise if !wait || e.reason != "not_done"
477
+ raise if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
478
+
479
+ sleep(poll)
480
+ end
481
+ end
482
+
483
+ # Fetch the job's log — the same framed view as +display+, not a raw solver
484
+ # log.
485
+ #
486
+ # @return [String]
487
+ def log
488
+ @client.request_raw("GET", "/v1/jobs/#{@job_id}/log")
489
+ end
490
+
491
+ # Delete the job and its stored blob, result and log on the server.
492
+ #
493
+ # @return [nil]
494
+ def delete
495
+ @client.request_raw("DELETE", "/v1/jobs/#{@job_id}")
496
+ nil
497
+ end
498
+
499
+ # @return [String] the job id, tagged with the class
500
+ def inspect
501
+ "#<Quicopt::Job #{@job_id}>"
502
+ end
503
+ end
504
+ end