Skip to content

Using a solver

This guide walks the whole consumer side. If you just want something running, start with the Quickstart.

Find one by name, or open a specific file:

import fznso
lib = fznso.Library.find("gecode") # newest installed
lib = fznso.Library.find("gecode", "6") # newest 6.x.y
lib = fznso.Library("/path/to/libgecode.so") # this exact file

The ABI version is checked here, before any other entry point is called; a mismatched solver fails to load rather than crashing later. If nothing is found, see Installing solvers.

A library declares its capabilities in five lists, fixed for its lifetime. Read them if you need to adapt; skip them if you already know the solver.

The capability lists are not exposed to Python yet. Read them from Rust or C++, or drive the solver by names you already know.

Do not pass a name that is not in these lists, and check the argument types too — a solver may accept a constraint only in a narrower form than the registry declares.

solver = lib.create_solver()

One instance holds one configuration and whatever search state survives between runs. Create several if you want to search several problems at once; instances do not share state.

The simplest route is LayeredModel, an in-memory model the bindings provide:

var_int = fznso.Type("int", decision=True)
model = fznso.LayeredModel()
x = model.add_decision(var_int, range(1, 11), name="x") # x in 1..10
y = model.add_decision(var_int, range(1, 11), name="y")
model.add_constraint("int_lin_le", [[1, 1], [x, y], 12]) # x + y <= 12
model.add_constraint("int_lin_ne", [[1, -1], [x, y], 0]) # x != y
model.set_objective("int_maximize", x)

If your application already holds the problem in its own structures, implement the model interface over them instead of copying into a LayeredModel. That is what the callback design is for. See The model.

solver.option_set("time_limit", 30_000) # milliseconds
solver.option_set("threads", 8)
solver.option_set("intermediate", True) # report improving solutions as they are found

Setting an option the solver did not declare fails and carries a message. The common options are in the registry.

best = None
def on_solution(sol):
global best
best = (sol[x], sol[y]) # copied out — see the warning below
print("found", best, "nodes:", sol.statistic("nodes"))
status = solver.run(
model,
on_solution=on_solution,
on_message=lambda scope, value: print(f"[{scope}] {value}"),
)

run returns one of three statuses: Complete (the search finished — with an objective, the last solution reported is optimal), Incomplete (it stopped early, so what you have is valid but not necessarily best), or Error.

Solver-scoped statistics stay readable after the run:

print(solver.statistic("nodes"), solver.statistic("solve_time"))

For a deadline set time_limit; for anything a deadline cannot express — a cancel button, “five solutions is enough” — pass a should_stop predicate. Stopping early covers why the first is not merely a special case of the second.

stop = threading.Event()
solver.run(model, on_solution=cb, should_stop=stop.is_set)

should_stop is the one callback you must make thread-safe. Reading an atomic flag, as above, already is.

Change the model and call run again on the same instance. That is what the interface is for; see Incremental solving.

The test suites drive a real solver end to end and are kept working by CI:

  • rust/fznso/tests/dylib.rs
  • cpp/tests/test_consumer.cpp
  • python/tests/test_fznso.py