#solver #graph #logic #reactive #no-std

no-std gpp-solver

A small hybrid push-pull solver/planner that has the best of both worlds

4 releases

0.2.2 Dec 29, 2021
0.2.1 Nov 27, 2021
0.2.0 Nov 27, 2021
0.1.0 Nov 25, 2021

#2646 in Algorithms

BSD-2-Clause

39KB
743 lines

Generic Push-Pull Solver.

This crate implements a generic solver for anything that can have a clear dependency graph. The implementation is a mix of push (eager) and pull (lazy) architectures with user-driven recursion.

Functionality is centered on the Solver struct. Users record all fragments they want to evaluate and only those. Fragments are represented by an integral FragmentId, but what is a fragment is arbitrary and the solver does not care. It may represent a variable, an action, an object, or anything else.

Users must also implement the Problem trait, which defines a dependency graph and an interface for evaluating fragments that the solver finds are both solvable and required. This dependency graph does not need to be complete or explicit, as long as implementors can return the direct dependencies of specified fragments as the solver explores the dependency graph.

Solver::run and Solver::step will incrementally explore the depedency graph and call Problem::evaluate on fragments that have all of its dependencies met.

In the end, all requested fragments will either have been evaluated or will be proven to be part of a dependency cycle. The user may choose to report cycles as errors, or break them with Solver::assume_evaluated or Solver::clone_with_evaluation_assumptions. See also Solver::status.

Solver::punted_iter will return an iterator yielding all fragments that have been punted so far. A punted fragment is one that has been considered for evaluation but its dependencies haven't been met yet. If the solver is done, punted fragments must be part of at least one cycle.

Concurrency

Solver is fully asynchronous but the core algorithm is not parallel at the moment. Running multiple Solver::step concurrently or calling Solver::run with concurrency > 1 is safe but will not make the solver itself run faster. What this does allow is for multiple Problem::direct_dependencies and Problem::evaluate calls to run concurrently.

Build Features

This crate has multiple features. From those, there are three where users must specify exactly one of: futures-lock, tokio-lock, or async-std-lock. Use whichever is most convenient.

std

Use std. Solver::run will be unavailable if std is disabled. TODO: actually make this assertion true.

js-bindings

Build the JavaScript API if building for WASM.

futures-lock

Use the locks implemented by the futures crate.

tokio-lock

Use the locks implemented by the tokio crate.

async-std-lock

Use the locks implemented by the async-lock crate.

Internals

Solver implements a hybrid push-pull architecture. Fragments are only evaluated if needed (pull, lazy evaluation), but instead of evaluating dependencies recursively, this process will only evaluate fragments that already have all of its direct dependencies met. If that's not the case, the fragment will be punted: stored away and only considered again if all its dependencies are met sometime in the future.

On the other hand, if a fragment is successfully evaluated, punted fragments that depend on it will be evaluated eagerly (push) if all other dependencies have also been evaluated.

This architecture has two major advantages:

  • It is lazy. Only fragments that are explicitly requested to be evaluated, and the fragments those depend on, will be evaluated. And never more than once.
  • There is no need to explicitly detect nor handle cycles, unlike both pure push and pure pull. Fragments that are part of cycles will naturally be punted and never considered again. Unless the cycle is explicitly broken with Solver::assume_evaluated or Solver::clone_with_evaluation_assumptions. This enables a much simpler implementation.

Dependencies

~1–9MB
~82K SLoC