Propositional Logic Directed Acyclic Graph (PL-DAG)
A PL-DAG is a directed acyclic graph for representing many related discrete logic models in one shared structure. Each leaf defines a discrete integer variable domain, and each internal node defines a reusable derived variable constrained by a linear relation over its input variables.
Instead of building a one-off optimization model, solving it, and then throwing it away or caching the result, a PL-DAG lets you keep the underlying logical structure alive as a modular graph. Large domains can be represented once, shared across use cases, and sliced into smaller computation-ready subgraphs when needed.
This makes PL-DAG useful as an abstraction layer above traditional ILP modelling. You can store commercial rules, technical rules, product baselines, feature dependencies, capacity constraints, and other logical representations in the same graph without having to compute over the whole thing every time.
The central operation is sub_dag: given one or more nodes of interest, it extracts only the transitive dependency graph needed for that computation. That slice can then be used for bound propagation or exported to a polyhedral form your favourite ILP solver can consume.
PL-DAGs are especially useful when a domain contains many overlapping logical models:
- many variants of the same model that differ along independent dimensions (region, segment, version, time period, …)
- commercial and technical/BOM layers that need to coexist
- feature toggles and dependency systems shared across many products
- capacity, compatibility, and selection rules reused in many contexts
- cross-model questions that are hard to ask when each model is built and solved in isolation
By breaking logic into small reusable nodes, a single PL-DAG can hold millions of nodes spanning a whole domain. Ordinary computations still operate on small extracted subgraphs, while broader questions can cross boundaries that would otherwise live in separate models.
✨ Key Features
| Area | What you get |
|---|---|
| Modelling | Build Boolean / linear constraint systems in a single graph representation. |
| Async storage | Mutating and storage-touching methods are async fn, so you can plug in any backend (Postgres, Redis, …). |
| Analysis | Efficient bound propagation over compiled DAG snapshots. |
| Export | to_sparse_polyhedron() emits a polyhedral ILP model ready for any external ILP solver. |
Typical Workflow
You keep one big graph that can hold millions of nodes representing all sorts of things in your domain — products, rules, capacities, feature flags — and pull out just the slice you need, whenever you need it.
The everyday loop is three steps:
- Populate — grow the DAG using
set_primitive,set_or,set_atleast, … Do it once, or keep adding to it as your domain evolves. - Sub-DAG — pick a node you care about and call
pldag.sub_dag(vec![node_id]).await?. You get back a self-containedCompiledDagof just the subgraph rooted at that node. - Compute — run
propagate,to_sparse_polyhedron, or any other analysis on that snapshot.
// 1. Populate (once, or as your domain evolves)
pldag.set_primitive.await?;
pldag.set_primitive.await?;
let root = pldag.set_or.await?;
// 2. Pick a node of interest, get its sub-DAG
let dag = pldag.sub_dag.await?;
// 3. Compute on it
let bounds = dag.propagate?;
That's it. The same PL-DAG can back any number of these slices, so a single shared model serves many use cases at once.
Install
You will also need an async runtime to drive the model-building calls. The examples below use tokio:
Architecture
A Pldag is a thin wrapper around a pluggable storage backend. The storage holds the DAG topology; the Pldag exposes the modelling API on top of it.
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐
│ Pldag │ ──► │ NodeStoreTrait │ ──► │ KeyValueStore │
│ (modelling) │ │ (DAG ops) │ │ (bytes / values) │
└──────────────┘ └────────────────┘ └──────────────────┘
│
▼
InMemoryStore / Your DB
KeyValueStoreis the low-level async key/value contract.NodeStoreTraitlayers DAG-specific operations (parents, children, primitives) on top.InMemoryStoreis the default backend (std::sync::RwLockunder the hood). For a custom backend, implement the two traits and pass it viaPldag::new_custom.
Because the traits are async, you can back the DAG with any database that has an async client.
Core Routines
Building the model
async ;
async
Create primitive (leaf) variables with specified bounds.
async
Creates a general linear inequality: sum(coeff_i * var_i) + bias >= 0.
async ;
async ;
async ;
sum(vars) >= value, sum(vars) <= value, sum(vars) == value.
Logical constraints
async ;
async ;
async ;
async ;
async ;
async ;
async ;
async ;
async ;
set_and: True iff all input variables are true.set_or: True iff at least one input variable is true.set_not: True iff the input variable is false.set_xor: True iff exactly one input variable is true.set_nand: True iff not all input variables are true.set_nor: True iff no input variables are true.set_xnor: True iff not exactly one input variable is true.set_imply: True iff the condition being true requires the consequence to be true. Equivalent to!condition OR consequence.set_equiv: True ifflhsandrhshave the same Boolean value.
Inspection / removal
async ;
async ;
async ;
delete_node errors with ModelError::NodeReferenced if other nodes still depend on the target.
Snapshotting & analysis
async ;
async ;
dag snapshots the entire DAG; sub_dag snapshots only the subgraph reachable from roots (or the entire DAG if roots is empty).
Once you hold a CompiledDag, all further analyses are synchronous and return [ComputeResult]:
propagate
Snapshot then compute: let dag = pldag.dag().await?; let bounds = dag.propagate(assignments)?;.
Export
to_sparse_polyhedron ;
to_dense_polyhedron ;
Both SparsePolyhedron and DensePolyhedron implement serde::Serialize, so you can ship them over HTTP to a remote ILP service or hand them to any in-process solver of your choice.
Errors
Fallible operations return one of two disjoint result types so each function's signature describes its actual failure modes:
ModelResult<T>/ModelError— operations that touch storage (set_*,delete_node,get_node(s),dag,sub_dag). Variants:EmptyConstraint,NodeNotFound,NodeReferenced, andBackend(StorageError)for underlying backend failures.ComputeResult<T>/ComputeError— pure in-memory operations on aCompiledDag(propagate, tighten, ranks, polyhedron export). Variants:NodeOutOfBounds,MaxIterationsExceeded,CycleDetected.
StorageError is the lower-level backend error returned by KeyValueStore / NodeStoreTrait implementations; it flows into ModelError::Backend via ?.
Quick Example
use HashMap;
use ;
async
Custom Storage Backend
Plug in your own database by implementing the two storage traits:
use async_trait;
use ;
use Value;
use HashMap;
use Arc;
async
NodeStore is the default NodeStoreTrait implementation layered on top of any KeyValueStore; if you want to override the DAG-level behaviour as well, implement NodeStoreTrait directly.
Notes
- All
set_*functions accept any iterable type whose items implementToString. Pldag::new()returns a model backed by an in-memory store. UsePldag::new_custom(store)to provide your own backend.- Storage backend failures surface as
ModelError::Backend(StorageError)and bubble up from?automatically via theFrom<StorageError> for ModelErrorimpl.
License
MIT.