quicopt
A Ruby client for the Quicopt optimization service.
It does exactly three things: author an optimization model in a small native Ruby DSL, encode it to the versioned Quicopt wire IR, and send it to the service. It is the Ruby peer of the Python and Julia clients — the same wire contract, the same results.
There is no local solver here. The service does the solving.
Install
gem "quicopt"
or
gem install quicopt
Ruby 3.2 or newer. The only runtime dependency is google-protobuf; the transport
is stdlib net/http.
Quick start
require "quicopt"
m = Quicopt::Model.new
x = m.int_var(0, 4, "x")
y = m.bin_var("y")
z = m.num_var(0, 10, "z")
m.maximize(3 * x + 2 * y + z)
m.add(x + y <= 5)
result = Quicopt::Client.new.solve(m)
puts result.display
result.display is rendered by the service and ready to print — the same summary
for every kind of model. The structured fields are there too:
result.status # "optimal", "heuristic", …
result.objective # Float, or nil where the outcome leaves it undefined
result.feasible # true / false / nil
result.solution # {"x" => 4.0, "y" => 1.0, "z" => 10.0}
result.solve_time_seconds
The model
Variables
m.num_var(0, 10, "z") # continuous
m.int_var(0, 4, "x") # integer
m.bin_var("y") # binary
m.num_var(nil, nil, "w") # free: unbounded in both directions
The name is optional and generated when omitted, but solutions come back keyed by
it, so naming your variables is worth it. start: sets the initial point; it
defaults to 0 clamped into the bounds.
Integer variables with bounds of exactly [0, 1] are declared binary — the same
normalisation the other clients apply, so one model reaches the service as one
program whichever language authored it.
Expressions
+, -, *, / and ** build expressions, and a number may sit on either
side:
3 * x + 2 * y - 4
(x + 1) * (y + 2) # quadratic
z**2 # quadratic
x / 2 # division by a constant
Expressions are normalised as they are built — a constant plus linear terms plus
quadratic terms — so x * y and y * x are the same term, and x - x is zero.
Only linear and quadratic expressions exist. Anything beyond that raises at the point you write it, rather than being silently dropped or approximated:
x * y * z # Quicopt::UnsupportedExpression: exceeds the quadratic subset
x / y # Quicopt::UnsupportedExpression: division by a variable
x**3 # Quicopt::UnsupportedExpression
Constraints
<=, >= and == build constraint rows, not booleans:
m.add(x + y <= 5)
m.add(2 * x - z >= -1)
m.add(x + z == 3)
Two consequences of that, both deliberate:
x == 5is a row. Comparing a variable to something that could never be part of an expression (x == nil) still answersfalse, and variables remain usable as Hash and Set keys, but do not expect==to test equality.!=has no wire form and raises rather than quietly answeringfalse.- A number on the left works (
5 <= x), because Ruby hands the comparison back to the expression.5 == x, however, is Ruby's ownInteger#==and is justfalse— writex == 5.
Chained comparisons (1 <= x <= 5) are not a thing in Ruby. Add two rows.
Working with the wire bytes
The encoded program is not hidden. Inspect it, cache it, or route it yourself:
bytes = m.encode # binary protobuf, ready to POST
program = m.to_program # the structured wire IR
Quicopt.decode(bytes) # back to a program
Quicopt::Client.new.solve(bytes) # solve pre-encoded bytes directly
The wire schema is proto/quicopt/modeler/v1/program.proto, vendored in this
repository; gen/gen.sh regenerates the codec from it.
Note that the generated codec omits zero-valued scalars and does not canonicalize field order, so two encodings of the same model are decode-equivalent rather than byte-identical. Compare decoded programs, never hex.
The client
client = Quicopt::Client.new # the public free tier
client = Quicopt::Client.new("http://localhost:8137")
client = Quicopt::Client.new(url, "my-api-key", timeout: 120)
Keys. The first call without a key mints one, cached at
$XDG_CACHE_HOME/quicopt/free_key (~/.cache/… by default) and replayed as a
bearer token on every later call — including from later runs, so you keep one key
without doing anything. A key you passed in is used as-is, never cached and never
overwritten; Quicopt::Client.new(url, cache: false) keeps the minted key in
memory only.
Where the home directory does not survive the run (CI, containers), the cache is
wiped between sessions and each run mints a new key. Set QUICOPT_KEY_PATH to a
durable location — or Client.new(url, key_path: …) — to keep one key across
sessions.
Per-project tagging. solve(model, project: "atlas") tags the call so usage on
one key can be attributed per project. It rides the query string and is never
baked into the model.
Large models. solve(model, gzip: true) compresses the request body.
Asynchronous solving. Use this for the first call against a server that may still be warming up, where a blocking call could time out:
job = client.submit(m)
job.status # {"status" => "running", …}
result = job.result # polls until done
job.log # the framed log view
job.delete # remove the job and its stored data
Errors. A non-2xx response raises Quicopt::QuicoptError, which carries a
stable machine-readable reason — match on that, not on the message:
begin
client.solve(m)
rescue Quicopt::QuicoptError => e
puts e.display # ready-to-print, server-rendered
retry if e.reason == "queue_busy"
raise
end
Everything this gem raises descends from Quicopt::Error.
Development
bundle install
bundle exec rake # run the tests
gen/gen.sh # regenerate the codec after a schema change
The client tests run against a real local HTTP server rather than a stubbed
Net::HTTP, so the transport — headers, query encoding, gzip framing, key
capture, the error path — is covered end to end.
Licence
Apache-2.0. See LICENSE.