Implementing a solver
Implementing a solver means exporting thirteen entry points from a dynamically loadable library. The Rust and C++ bindings each provide a macro that writes all thirteen, leaving you the few methods that differ between solvers.
Decide first
Section titled “Decide first”Your solver’s name, which goes in every symbol (fznso_gecode_solver_run) and must match the library’s file name: a valid C identifier not starting with lib.
See Naming & versioning.
What you will declare. Start with all five capability lists empty and add as you go — declaring nothing is conforming — remembering that declaring a registry name commits you to the registry’s meaning.
The methods that matter
Section titled “The methods that matter”Everything else is bookkeeping the macros handle.
option_setandoption_get— accept the options you declared, reject the rest with a message.run— read the model, search, report each solution, return a status.statistic— report the solver-scoped statistics you declared, absent for anything else.
Reading the model
Section titled “Reading the model”The model is a callback table, not a structure. Ask it what it holds:
for d in model.decisions() { let ty = d.ty(); let domain = d.domain(); // absent if the variable has no explicit domain let wanted = d.in_solution(); // if true, every solution must assign it}
for c in model.constraints() { match c.ident() { "int_lin_le" => post_lin_le(c.arguments()), // Anything you did not declare in `constraint_list` cannot reach you. other => unreachable!("undeclared constraint {other}"), }}Two rules govern everything you read, and both are stated in full: ask a value its kind before reading it, and treat what you read as borrowed for the duration of run.
Reporting
Section titled “Reporting”Call the solution callback once per solution. The values you hand it are borrowed from you, so the storage behind them must outlive the call: build it before reporting, not inside the accessor.
Honour the two nullable callbacks.
A null on_message means skip building the diagnostic, not build it and discard it — both bindings expose this as a wanted() check — and a null should_stop means drop the poll from your search loop rather than call a predicate that always answers false.
Failing
Section titled “Failing”Return FznsoError from run, or a message from option_set; the caller then asks for the reason through fznso_<name>_solver_read_error.
Do not report failures through on_message, which is for diagnostics the search survived.
A complete example
Section titled “A complete example”Deliberately trivial — it assigns every decision the lower bound of its domain and ignores the constraints — so the shape is what shows through. The test suite builds and runs it on every commit, so it cannot drift from the interface.
//! A throwaway FZnSO solver, built as a `cdylib` example so the dynamic-loading//! path can be tested end to end.//!//! This is **not** a real solver: it assigns every decision variable the lower//! bound of its domain and reports that as a solution, ignoring the constraints//! entirely. It exists so `tests/dylib.rs` (and the C++ and Python suites) have//! something to `dlopen`, exercising [`fznso::Library`], [`fznso::DynSolver`]//! and the entry points generated by [`fznso::fznso_export`].//!//! Being an example rather than its own crate, it is the canonical Rust solver//! implementation the whole workspace loads. Replace it once a real solver//! exists to test against.
#![allow(missing_docs, reason = "the exported ABI entry points are generated")]
use std::marker::PhantomData;
use fznso::{ Model, Solution, Solver, SolverType, Statistic, StatisticList, Status, Str, Type, TypeBase, Value, ValueExt, ValueView,};
/// The assignment produced by [`ExampleSolver`].#[derive(Debug)]pub struct ExampleSolution { /// The value assigned to each decision variable, indexed by decision index. values: Vec<i64>, /// How many solutions had been emitted when this one was produced. index: i64,}
impl Solution for ExampleSolution { fn statistic(&self, name: &str) -> Value<'_> { match name { "solutions" => (&self.index).into(), _ => (&()).into(), } }
fn value(&self, decision_idx: usize) -> Value<'_> { self.values .get(decision_idx) .map_or_else(|| (&()).into(), Into::into) }}
/// A solver that reports domain lower bounds, ignoring all constraints.#[derive(Debug, Default)]pub struct ExampleSolver { /// Value of the `solution_limit` option: how many solutions to emit. solution_limit: i64, /// How many times [`Solver::run`] has been called, reported as the /// solver-level `runs` statistic. runs: i64,}
/// An integer statistic, the only shape this fixture reports.const INT_STAT: Type = Type { list_of: false, decision: false, set_of: false, opt: false, base: TypeBase::FznsoTypeBaseInt,};
/// The statistics this fixture reports, one of each scope: `solutions` is read/// from a solution, `runs` from the solver instance. A statistic may also set/// both flags, in which case either route works.const STATS: &[Statistic<'static>] = &[ Statistic { ident: Str::new("solutions"), ty: INT_STAT, solution: true, solver: false, lifetime: PhantomData, }, Statistic { ident: Str::new("runs"), ty: INT_STAT, solution: false, solver: true, lifetime: PhantomData, },];
impl Solver for ExampleSolver { type Solution<'s> = ExampleSolution;
fn option_get(&self, name: &str) -> Value<'_> { match name { "solution_limit" => (&self.solution_limit).into(), _ => (&()).into(), } }
fn statistic(&self, name: &str) -> Value<'_> { match name { "runs" => (&self.runs).into(), _ => (&()).into(), } }
fn option_set(&mut self, name: &str, value: Value<'_>) -> Result<(), String> { match name { "solution_limit" => { self.solution_limit = i64::try_from(&value) .map_err(|()| "solution_limit expects an integer".to_owned())?; Ok(()) } _ => Err(format!("unknown option `{name}`")), } }
fn run<M, F, G, H>( &mut self, model: &M, on_solution: &mut F, on_message: Option<&mut G>, should_stop: Option<&H>, ) -> Status where M: Model + Sync, F: for<'s> FnMut(&'s Self::Solution<'s>) + Send, G: FnMut(&str, Value<'_>) + Send, H: Fn() -> bool + Send + Sync, { self.runs += 1;
// This fixture ignores the constraints, which is worth saying out loud // — but only builds the message if someone is listening for it. let mut on_message = on_message; if let Some(emit) = &mut on_message { let warning = format!("ignoring {} constraint(s)", model.constraint_len()); emit("warn", (&warning).into()); }
// The annotations on the model, when it has any. This fixture ignores // their advice, but reporting them lets a test see that they crossed. // Arguments are read as integers, which is all its callers use. if on_message.is_some() { let mut anns: Vec<String> = Vec::new(); let mut push = |site: String, ann: fznso::AnnotationRef<'_>| { let args: Vec<String> = ann.arguments().map(|v| v.get_int().to_string()).collect(); anns.push(format!("{site}:{}({})", ann.ident(), args.join(","))); }; for (i, decision) in model.decisions().enumerate() { for ann in decision.annotations() { push(format!("d{i}"), ann); } } for (i, con) in model.constraints().enumerate() { for ann in con.annotations() { push(format!("c{i}"), ann); } } for ann in model.objective().annotations() { push("obj".to_owned(), ann); } if let Some(emit) = &mut on_message && !anns.is_empty() { emit("annotations", (&anns.join(" ")).into()); } }
// Report the per-layer decision counts when the model has more than one // layer, so tests can observe that the layer structure came through. if model.layer_len() > 1 && on_message.is_some() { let counts: Vec<String> = model .layers() .map(|layer| layer.decisions().count().to_string()) .collect(); if let Some(emit) = &mut on_message { emit("layers", (&counts.join(",")).into()); }
// The three counts that say how much of the model the solver may // reuse. Nothing else lets a test see that they crossed at all. let redundant: Vec<String> = (0..model.layer_redundant_len()) .map(|i| model.layer_redundant_index(i).to_string()) .collect(); let reuse = format!( "unchanged={};permanent={};redundant={}", model.layer_unchanged(), model.layer_permanent(), redundant.join(",") ); if let Some(emit) = &mut on_message { emit("layers.reuse", (&reuse).into()); }
// Also read back the model's strings and a constraint argument, so // tests can observe those cross the interface too. let name0 = model .decision(fznso::DecisionIdx::from(0)) .name() .unwrap_or(""); let mut summary = format!("obj={};name0={}", model.objective().ident(), name0); if model.constraint_len() > 0 { let con = model.constraint(fznso::ConstraintIdx::from(0)); summary += &format!(";con0={}({})", con.ident(), con.argument(0).get_int()); } if let Some(emit) = &mut on_message { emit("model", (&summary).into()); } }
// Lower bound of each decision's domain, or 0 when it has none. let base: Vec<i64> = model .decisions() .map(|d| match d.domain().view() { ValueView::IntSet(set) if !set.is_empty() => set.get(0).0, _ => 0, }) .collect();
// Absent means "never asks to stop", so there is nothing to poll. let stopped = || should_stop.is_some_and(|poll| poll()); // Polled before starting, and again after each solution is reported, so // a caller can stop from inside its own callback. if stopped() { return Status::Incomplete; } for i in 0..self.solution_limit { // Shift the first variable each round so successive solutions // differ. let mut values = base.clone(); if let Some(first) = values.first_mut() { *first += i; } on_solution(&ExampleSolution { values, index: i }); if stopped() { return Status::Incomplete; } } Status::Complete }}
impl SolverType for ExampleSolver { /// The statistic list is declared so the `solution`/`solver` scope flags /// are exercised; the other capability lists keep their empty default. const STATISTIC_LIST: StatisticList<'static> = StatisticList { len: STATS.len(), stats: STATS.as_ptr(), lifetime: PhantomData, };
fn new() -> Self { Self { solution_limit: 1, runs: 0, } }}
// The library name matches the built cdylib's base name// (`libexample_solver.*`), so `Library::new` recovers it from the file name.fznso::fznso_export!(ExampleSolver, "example_solver");// A throwaway C++ solver, built as a dynamically loadable library so the export path can be// exercised end to end.//// It is behaviourally identical to the Rust example solver, so the same// `test_consumer` harness and assertions drive both: it assigns every decision// the lower bound of its domain, shifts the first by the solution index, and// warns that it ignores the constraints.
#include "fznso_export.hpp"
#include <cstdint>#include <optional>#include <string>#include <string_view>#include <vector>
namespace {
/// The assignment the solver reports for one solution.class ExampleSolution final : public fznso::SolutionSource {public: ExampleSolution(std::vector<std::int64_t> values, std::int64_t index) : values_(std::move(values)), index_(index) {}
fznso::Value value(fznso::Decision decision) const override { return decision.index < values_.size() ? fznso::Value{values_[decision.index]} : fznso::Value{}; } fznso::Value statistic(std::string_view name) const override { return name == "solutions" ? fznso::Value{index_} : fznso::Value{}; }
private: std::vector<std::int64_t> values_; std::int64_t index_;};
/// A solver that reports domain lower bounds, ignoring all constraints.class ExampleSolver final : public fznso::Solver {public: fznso::Value option_get(std::string_view name) const override { return name == "solution_limit" ? fznso::Value{solution_limit_} : fznso::Value{}; }
fznso::Value statistic(std::string_view name) const override { return name == "runs" ? fznso::Value{runs_} : fznso::Value{}; }
/// One statistic of each scope: `solutions` is read from a solution, `runs` /// from the solver instance. A statistic may also set both flags. static FznsoStatisticList statistic_list() { static const FznsoType int_stat = fznso::Type{FznsoTypeBaseInt}; static const FznsoStatistic stats[] = { {fznso::str("solutions"), int_stat, true, false}, {fznso::str("runs"), int_stat, false, true}, }; return {sizeof(stats) / sizeof(stats[0]), stats}; }
std::optional<std::string> option_set(std::string_view name, fznso::Value value) override { if (name == "solution_limit") { solution_limit_ = value.as_int(); return std::nullopt; } return "unknown option `" + std::string{name} + "`"; }
fznso::Status run(const fznso::Model& model, fznso::SolutionSink& solutions, fznso::MessageSink& messages, const fznso::StopSignal& stop) override { ++runs_; // Polled before starting, and again after each solution is reported, so a // caller can stop from inside its own callback. if (stop.requested()) { return fznso::Status{fznso::Status::Kind::Incomplete, {}}; }
// This fixture ignores the constraints, which is worth saying out loud — but // only builds the message if someone is listening for it. if (messages.wanted()) { std::string warning = "ignoring " + std::to_string(model.constraint_count()) + " constraint(s)"; messages.message("warn", fznso::Value{warning}); }
// Lower bound of each decision's domain, or 0 when it has none. std::vector<std::int64_t> base; for (std::size_t i = 0; i < model.decision_count(); ++i) { fznso::ValueVariant domain = model.decision_domain(fznso::Decision{i}).variant(); const auto* set = std::get_if<fznso::IntRanges>(&domain); base.push_back(set != nullptr && !set->empty() ? (*set)[0].min : 0); }
for (std::int64_t i = 0; i < solution_limit_; ++i) { // Shift the first variable each round so successive solutions differ. std::vector<std::int64_t> values = base; if (!values.empty()) { values[0] += i; } solutions.solution(ExampleSolution{std::move(values), i}); if (stop.requested()) { return fznso::Status{fznso::Status::Kind::Incomplete, {}}; } } return fznso::Status{fznso::Status::Kind::Complete, {}}; }
private: std::int64_t solution_limit_ = 1; std::int64_t runs_ = 0;};
} // namespace
// The library name matches the built library's base name// (`libcpp_example_solver.*` / `cpp_example_solver.dll`), so a loader recovers// it from the file name.FZNSO_EXPORT_SOLVER(ExampleSolver, cpp_example_solver);Searching on several threads
Section titled “Searching on several threads”The threading contract applies: the model is yours to read from any number of threads, but the solution and message callbacks must be driven serially, so funnel them through one thread — as this example does.
//! A solver that does all of its work on a worker thread, so the threading//! contract in [`fznso::Solver::run`] is exercised end to end.//!//! It is deliberately trivial *as a solver*: it assigns every decision the same//! value and reports one solution. What matters is **where** the work happens.//! The model is read, and both callbacks are invoked, from a spawned thread —//! which is exactly what the contract permits and what a real parallel solver//! does. That makes this the fixture for two properties nothing else covers://!//! - the `M: Sync` / `F: Send` / `G: Send` bounds, and the `unsafe impl Sync`//! on `ModelRefAdapter`, are actually relied upon rather than merely//! declared;//! - a binding that holds a process-wide lock across the run — the Python GIL,//! say — deadlocks against this, so loading it proves the lock is released.//!//! The contract still requires reporting to be *serial*, which it is here://! only one thread ever calls the callbacks, and the main thread just waits.
#![allow(missing_docs, reason = "the exported ABI entry points are generated")]
use fznso::{Model, Solution, Solver, SolverType, Status, Value};
/// The solution reported from the worker thread.#[derive(Debug)]pub struct ThreadedSolution { /// How many decisions the worker read from the model, reported as every /// decision's value so a consumer can observe that the cross-thread read /// reached the real model. decisions: i64,}
impl Solution for ThreadedSolution { fn statistic(&self, name: &str) -> Value<'_> { match name { "decisions" => (&self.decisions).into(), _ => (&()).into(), } }
fn value(&self, _decision_idx: usize) -> Value<'_> { (&self.decisions).into() }}
/// A solver that reports from a thread other than the one `run` was called on.#[derive(Debug, Default)]pub struct ThreadedSolver;
impl Solver for ThreadedSolver { type Solution<'s> = ThreadedSolution;
fn option_get(&self, _name: &str) -> Value<'_> { (&()).into() }
fn option_set(&mut self, name: &str, _value: Value<'_>) -> Result<(), String> { Err(format!("unknown option `{name}`")) }
fn run<M, F, G, H>( &mut self, model: &M, on_solution: &mut F, on_message: Option<&mut G>, should_stop: Option<&H>, ) -> Status where M: Model + Sync, F: for<'s> FnMut(&'s Self::Solution<'s>) + Send, G: FnMut(&str, Value<'_>) + Send, H: Fn() -> bool + Send + Sync, { // A scoped thread, so the borrowed model and callbacks need no // `'static`. Reading the model here is what requires `M: Sync`; // reporting from here is what requires the callbacks to be `Send`. std::thread::scope(|scope| { let mut on_message = on_message; let stopped = || should_stop.is_some_and(|poll| poll()); let worker = scope.spawn(move || { if stopped() { return Status::Incomplete; } let decisions = i64::try_from(model.decision_len()).unwrap_or(-1); // Only formatted when someone is listening. if let Some(emit) = &mut on_message { let note = format!("read {decisions} decision(s) from a worker thread"); emit("log", (¬e).into()); } on_solution(&ThreadedSolution { decisions }); if stopped() { return Status::Incomplete; } Status::Complete }); worker .join() .unwrap_or_else(|_| Status::Error("the worker thread panicked".to_owned())) }) }}
impl SolverType for ThreadedSolver { /// This fixture declares no capabilities; only the constructor is required. fn new() -> Self { Self }}
// The library name matches the built cdylib's base name// (`libthreaded_solver.*`), so `Library::new` recovers it from the file name.fznso::fznso_export!(ThreadedSolver, "threaded_solver");Checking your work
Section titled “Checking your work”The test consumer exercises the whole interface — options, statistics, solutions, messages, early stopping, and a model published back across the interface and compared against the original:
just test-cpp # builds and runs test_consumer./target/test_consumer /path/to/libmysolver.soIf your solver is in Rust, run the suite under miri as well. Every bug in the handle-casting class this interface can produce passed the ordinary test suite first.