Using a solver
The whole consumer side. For something running in twenty lines, start with the Quickstart.
1. Open a library
Section titled “1. Open a library”Find one by name, or open a specific file:
import fznso
lib = fznso.Library.find("gecode") # newest installedlib = fznso.Library.find("gecode", "6") # newest 6.x.ylib = fznso.Library("/path/to/libgecode.so") # this exact fileuse fznso::Library;
let lib = unsafe { Library::find("gecode") }?;let lib = unsafe { Library::find_version("gecode", "6") }?;let lib = unsafe { Library::new("/path/to/libgecode.so") }?;Opening a library is unsafe because it runs the file’s initialisers: you are trusting the file.
Everything past this point is safe.
#include "fznso.hpp"
std::unique_ptr<fznso::Library> lib = fznso::Library::find("gecode");lib = fznso::Library::find_version("gecode", "6");fznso::Library exact{"/path/to/libgecode.so"};The ABI version is checked here, before any other entry point is called, so a mismatched solver fails to load rather than crashing later. If nothing is found, see Installing solvers.
2. Ask what it can do
Section titled “2. Ask what it can do”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.
lib.constraint_types() # identifiers and argument typeslib.decision_types() # decision-variable types it can createlib.objectives() # objective strategieslib.options() # options, with types and defaultslib.statistics() # statistics, and where each can be read fromEach call copies the library’s list into ordinary Python objects — opt.ident, opt.argument_type, opt.default — so read them once and keep what you need.
lib.constraint_types(); // identifiers and argument typeslib.decision_types(); // decision-variable types it can createlib.objective_list(); // objective strategieslib.option_list(); // options, with types and defaultslib.statistic_list(); // statistics, and where each can be read fromlib->constraint_types(); // identifiers and argument typeslib->decision_types(); // decision-variable types it can createlib->objectives(); // objective strategieslib->options(); // options, with types and defaultslib->statistics(); // statistics, and where each can be read fromDo 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.
3. Describe the problem
Section titled “3. Describe the problem”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..10y = model.add_decision(var_int, range(1, 11), name="y")model.add_constraint("int_lin_le", [[1, 1], [x, y], 12]) # x + y <= 12model.add_constraint("int_lin_ne", [[1, -1], [x, y], 0]) # x != ymodel.set_objective("int_maximize", x)use fznso::{LayeredModel, OwnedValue, RangeList, Type, TypeBase};
let var_int = Type::new(TypeBase::FznsoTypeBaseInt).decision(true);let mut model = LayeredModel::default();let x = model.add_decision( var_int, OwnedValue::IntSet(RangeList::from(1_i64..=10)), Some("x".into()), false, true, vec![],);let y = model.add_decision( var_int, OwnedValue::IntSet(RangeList::from(1_i64..=10)), Some("y".into()), false, true, vec![],);// x + y <= 12let _ = model.add_constraint( "int_lin_le", vec![ OwnedValue::IntList(vec![1, 1]), OwnedValue::List(vec![OwnedValue::Decision(x), OwnedValue::Decision(y)]), OwnedValue::Int(12), ], None, vec![],);model.set_objective(Some("int_maximize"), OwnedValue::Decision(x), vec![]);const fznso::Type INT_VAR = fznso::Type{FznsoTypeBaseInt}.decision(true);
fznso::LayeredModel model;fznso::Decision x = model.add_decision(INT_VAR, fznso::OwnedValue::int_range(1, 10), "x");fznso::Decision y = model.add_decision(INT_VAR, fznso::OwnedValue::int_range(1, 10), "y");// x + y <= 12model.add_constraint("int_lin_le", {fznso::OwnedValue::int_list({1, 1}), fznso::OwnedValue::list({fznso::OwnedValue{x}, fznso::OwnedValue{y}}), fznso::OwnedValue{std::int64_t{12}}});model.set_objective("int_maximize", fznso::OwnedValue{x});If your application already holds the problem in its own structures, implement the model interface over them rather than copying into a LayeredModel.
That is what the callback design is for.
4. Create, configure, run
Section titled “4. Create, configure, run”One instance holds one configuration and whatever search state survives between runs. Create several to search several problems at once; instances share nothing.
solver = lib.create_solver()solver.option_set("time_limit", 30_000) # millisecondssolver.option_set("threads", 8)solver.option_set("intermediate", True) # report improving solutions as they are found
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}"),)let mut solver = lib.create_solver();solver.option_set("time_limit", (&30_000_i64).into())?; // millisecondssolver.option_set("threads", (&8_i64).into())?;solver.option_set("intermediate", (&true).into())?;
let mut best = None;let status = solver.run( &model, &mut |sol: &fznso::DynSolution<'_>| best = Some(sol.value(0).get_int()), Some(&mut |scope: &str, value: fznso::Value<'_>| eprintln!("[{scope}] {value:?}")), fznso::dont_interrupt(),);fznso::DynSolver solver = lib->create_solver();solver.option_set("time_limit", fznso::OwnedValue{std::int64_t{30000}}); // millisecondssolver.option_set("threads", fznso::OwnedValue{std::int64_t{8}});solver.option_set("intermediate", fznso::OwnedValue{true});
std::optional<std::int64_t> best;fznso::Status status = solver.run( model, [&](const fznso::Solution& sol) { best = sol[x].as_int(); }, [&](std::string_view scope, const fznso::Value& v) { std::cerr << '[' << scope << "] " << v.as_string() << '\n'; });Setting an option the solver did not declare fails and carries a message; the common options are in the registry.
5. Read the outcome
Section titled “5. Read the outcome”A run that finished reports one of two statuses: Complete, meaning the search space was exhausted — so with an objective, the last solution reported is optimal — or Incomplete, meaning it stopped early and what you have is valid but not necessarily best.
A solver that could not continue reports a failure instead, carrying its own message.
The bindings call solver_read_error for you, so that message arrives with the failure:
try: status = solver.run(model, on_solution=on_solution)except RuntimeError as err: print("solver failed:", err) # the solver's own messageelse: print(status) # Status.Complete or Status.Incompletematch status { Status::Complete => println!("optimal: {best:?}"), Status::Incomplete => println!("best found: {best:?}"), Status::Error(message) => eprintln!("solver failed: {message}"),}if (status.failed()) { std::cerr << "solver failed: " << status.error << '\n';} else if (status.complete() && best) { std::cout << "optimal: " << *best << '\n';}option_set fails the same way, and for the same reason: a message rather than a status — Result<(), String> in Rust, std::optional<std::string> in C++, a raised RuntimeError in Python.
Solver-scoped statistics stay readable after the run:
print(solver.statistic("nodes"), solver.statistic("solve_time"))println!("{}", solver.statistic("nodes").get_int());std::cout << solver.statistic("nodes").as_int() << '\n';6. Stopping early
Section titled “6. Stopping early”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 a special case of the second.
stop = threading.Event()solver.run(model, on_solution=cb, should_stop=stop.is_set)let stop = AtomicBool::new(false);solver.run( &model, &mut on_solution, fznso::ignore_messages(), Some(&|| stop.load(Ordering::Relaxed)),);std::atomic<bool> stop{false};solver.run(model, on_solution, [](std::string_view, const fznso::Value&) {}, [&] { return stop.load(std::memory_order_relaxed); });should_stop is the one callback you must make thread-safe; reading an atomic flag, as above, already is.
7. Run again
Section titled “7. Run again”Change the model and call run again on the same instance — see Incremental solving.
Complete consumers, kept working by CI: rust/fznso/tests/dylib.rs, cpp/tests/test_consumer.cpp, python/tests/test_fznso.py.