diff --git a/crates/aterm/src/random_term.rs b/crates/aterm/src/random_term.rs index 28eef458d..bcf406a3b 100644 --- a/crates/aterm/src/random_term.rs +++ b/crates/aterm/src/random_term.rs @@ -1,18 +1,18 @@ #![forbid(unsafe_code)] #[cfg(test)] -mod inner { - pub use rand::Rng; - pub use rustc_hash::FxHashSet; - - pub use crate::ATerm; - pub use crate::Symbol; - pub use crate::Term; - pub use crate::storage::THREAD_TERM_POOL; -} +use rand::Rng; +#[cfg(test)] +use rustc_hash::FxHashSet; #[cfg(test)] -use inner::*; +use crate::ATerm; +#[cfg(test)] +use crate::Symbol; +#[cfg(test)] +use crate::Term; +#[cfg(test)] +use crate::storage::THREAD_TERM_POOL; /// Create a random term consisting of the given symbol and constants. Performs /// iterations number of constructions, and uses chance_duplicates to choose the diff --git a/crates/aterm/src/storage/global_aterm_pool.rs b/crates/aterm/src/storage/global_aterm_pool.rs index d054c95cf..1c16031a8 100644 --- a/crates/aterm/src/storage/global_aterm_pool.rs +++ b/crates/aterm/src/storage/global_aterm_pool.rs @@ -37,6 +37,10 @@ pub(crate) static GLOBAL_TERM_POOL: LazyLock /// Enables aggressive garbage collection, which is used for testing. pub(crate) const AGGRESSIVE_GC: bool = false; +/// The number of terms a thread pool reserves from the shared budget at a time, see +/// [crate::storage::ThreadTermPool]. +pub(crate) const GC_BUDGET_CHUNK: usize = 4096; + /// A type alias for the global term pool guard pub(crate) type GlobalTermPoolGuard<'a> = RecursiveLockReadGuard<'a, GlobalTermPool>; @@ -73,7 +77,7 @@ pub(crate) struct GlobalTermPool { /// The number of terms that may still be created before garbage collection should be /// triggered. Set to roughly `capacity - len` after each collection and consumed by the - /// thread pools in chunks (see [crate::storage::ThreadTermPool]) to avoid contention. + /// thread pools in [GC_BUDGET_CHUNK] sized chunks to avoid contention on this counter. gc_budget: AtomicUsize, /// Default terms @@ -269,22 +273,18 @@ impl GlobalTermPool { } } - /// Triggers garbage collection if necessary, refreshes the global budget and returns the - /// per-thread chunk the calling thread pool should count down before touching the budget - /// again. - pub(crate) fn trigger_garbage_collection(&mut self) -> usize { + /// Triggers garbage collection if necessary and refreshes the global budget. + pub(crate) fn trigger_garbage_collection(&mut self) { if self.garbage_collection { // Garbage collection is enabled. self.collect_garbage(); } - self.reset_gc_budget() + self.reset_gc_budget(); } - /// Recomputes the global GC budget from the free storage capacity and returns the per-thread - /// chunk (the budget divided over the registered thread pools, to avoid every thread - /// contending on the shared counter). - pub(crate) fn reset_gc_budget(&self) -> usize { + /// Recomputes the global GC budget from the free storage capacity. + pub(crate) fn reset_gc_budget(&self) { let budget = if AGGRESSIVE_GC { 1 } else { @@ -292,7 +292,6 @@ impl GlobalTermPool { }; self.gc_budget.store(budget, Ordering::Relaxed); - (budget / self.num_thread_pools()).max(1) } /// Subtracts `amount` from the global GC budget, saturating at zero so it never wraps, and @@ -306,17 +305,6 @@ impl GlobalTermPool { .expect("the update closure always returns Some") } - /// Returns the current per-thread budget chunk without recomputing the global budget. Used - /// by a newly registered thread pool to obtain its initial counter. - pub(crate) fn gc_budget_chunk(&self) -> usize { - (self.gc_budget.load(Ordering::Relaxed) / self.num_thread_pools()).max(1) - } - - /// Returns the number of registered (live) thread pools, at least one. - fn num_thread_pools(&self) -> usize { - self.thread_pools.iter().flatten().count().max(1) - } - /// Enables or disables automatic garbage collection. pub fn automatic_garbage_collection(&mut self, enabled: bool) { self.garbage_collection = enabled; diff --git a/crates/aterm/src/storage/thread_aterm_pool.rs b/crates/aterm/src/storage/thread_aterm_pool.rs index 09e46be25..b3f4d01e0 100644 --- a/crates/aterm/src/storage/thread_aterm_pool.rs +++ b/crates/aterm/src/storage/thread_aterm_pool.rs @@ -35,6 +35,7 @@ use crate::storage::GlobalTermPoolGuard; use crate::storage::MAX_FIXED_ARITY; use crate::storage::SharedTerm; use crate::storage::SharedTermProtection; +use crate::storage::global_aterm_pool::GC_BUDGET_CHUNK; use crate::storage::global_aterm_pool::GLOBAL_TERM_POOL; thread_local! { @@ -56,14 +57,11 @@ pub struct ThreadTermPool { send_term_protection_set: Arc>>, /// Counts down the number of terms this thread may still create before it must consume the - /// next chunk from the global budget (see [GlobalTermPool::reset_gc_budget]). + /// next [GC_BUDGET_CHUNK] from the global budget (see [GlobalTermPool::reset_gc_budget]). + /// + /// Zero means this thread holds no reservation, so the next term creation has to claim one. garbage_collection_counter: Cell, - /// The size of a single budget chunk: the amount subtracted from the global budget, and the - /// value [Self::garbage_collection_counter] is refilled to, each time the counter reaches - /// zero. - garbage_collection_chunk: Cell, - /// A vector of terms that are used to store the arguments of a term for lookup. tmp_arguments: RefCell>>, @@ -89,15 +87,13 @@ impl ThreadTermPool { let empty_list_symbol = pool.get_empty_list_symbol().copy(); let list_symbol = pool.get_list_symbol().copy(); - // Claim a share of the current global budget as this thread's initial chunk. - let chunk = pool.gc_budget_chunk(); drop(pool); Self { protection_sets, send_term_protection_set, - garbage_collection_counter: Cell::new(chunk), - garbage_collection_chunk: Cell::new(chunk), + // Start without a reservation so the first created term claims (and charges) one. + garbage_collection_counter: Cell::new(0), tmp_arguments: RefCell::new(Vec::new()), int_symbol, empty_list_symbol, @@ -465,28 +461,26 @@ impl ThreadTermPool { guard.automatic_garbage_collection(enabled); } - /// Forces a garbage collection to occur, regardless of the current counter value or whether it is enabled. + /// Forces a garbage collection to occur regardless of the current GC budget. pub fn force_collect_garbage(&self) { let mut guard = self.term_pool.write().expect("Lock poisoned!"); guard.collect_garbage(); - self.set_budget_chunk(guard.reset_gc_budget()); + guard.reset_gc_budget(); + self.drop_budget_reservation(); } - /// Perform a garbage collection. + /// Perform a garbage collection if the global aterm pool is not locked. pub fn collect_garbage(&self) { - if !self.term_pool.is_locked() { - // Trigger garbage collection and acquire a fresh budget chunk. - if let Some(mut guard) = self.term_pool.try_write().expect("Lock poisoned!") { - self.set_budget_chunk(guard.trigger_garbage_collection()); - } + if let Some(mut guard) = self.term_pool.try_write().expect("Lock poisoned!") { + guard.trigger_garbage_collection(); + self.drop_budget_reservation(); } } - /// Records the per-thread budget chunk returned by the global pool, resetting the local - /// counter to count it down. - fn set_budget_chunk(&self, chunk: usize) { - self.garbage_collection_chunk.set(chunk); - self.garbage_collection_counter.set(chunk); + /// Drops this thread's outstanding reservation after the global budget was refreshed, so that + /// the next created term claims a chunk from (and charges it to) the new budget. + fn drop_budget_reservation(&self) { + self.garbage_collection_counter.set(0); } /// Triggers delayed garbage collection if the counter has reached zero. @@ -519,27 +513,24 @@ impl ThreadTermPool { self.trigger_garbage_collection(); } - /// Consumes the next budget chunk once the local counter is exhausted, triggering garbage + /// Reserves the next budget chunk once the local counter is exhausted, triggering garbage /// collection when the shared global budget has run out. fn trigger_garbage_collection(&self) { if self.garbage_collection_counter.get() == 0 && !self.term_pool.is_locked() { - let chunk = self.garbage_collection_chunk.get(); - - // Subtract this thread's chunk from the shared budget. The read guard is only needed - // to reach the atomic and is dropped before a potential collection acquires the write - // lock. + // Subtract a chunk from the shared budget. The read guard is only needed to reach the + // atomic and is dropped before a potential collection acquires the write lock. let previous = self .term_pool .read_recursive() .expect("Lock poisoned!") - .consume_gc_budget(chunk); + .consume_gc_budget(GC_BUDGET_CHUNK); - if previous <= chunk { - // The global budget is exhausted, so collect and obtain a fresh chunk. + if previous <= GC_BUDGET_CHUNK { + // The global budget is exhausted, so collect and start over on a fresh budget. self.collect_garbage(); } else { - // Budget remains; refill the local counter to count down the next chunk. - self.garbage_collection_counter.set(chunk); + // The chunk is now charged to the budget, so count it down locally. + self.garbage_collection_counter.set(GC_BUDGET_CHUNK); } } } @@ -652,7 +643,6 @@ impl DerefMut for ProtectionSetGuard<'_> { #[cfg(test)] mod tests { - use std::mem::ManuallyDrop; use std::sync::mpsc; use crate::ATerm; @@ -748,7 +738,7 @@ mod tests { // Leak the term so it is still protected at thread teardown, exercising the orphan // adoption path. The bug we guard against is post-teardown read UB. - std::mem::forget(ManuallyDrop::new(term)); + std::mem::forget(term); }); handle.join().expect("Thread should join without panic"); diff --git a/crates/data/src/closed.rs b/crates/data/src/closed.rs new file mode 100644 index 000000000..9910e706f --- /dev/null +++ b/crates/data/src/closed.rs @@ -0,0 +1,66 @@ +use std::ops::ControlFlow; + +use ahash::AHashSet; +use merc_aterm::Term; +use merc_utilities::Step; + +use crate::DataExpressionRef; +use crate::is_data_variable; +use crate::visit_data_expr; + +/// Returns true iff `term` contains no data variables, i.e. it is a ground term. +/// +/// A closed term normalises to the same result under every substitution, which is what makes +/// it sound to cache its normal form across calls that pass different substitutions. +/// +/// Panics for binders and where clauses, which have no flat argument list. +pub fn is_closed<'a, 'b, T: Term<'a, 'b>>(term: &'b T) -> bool { + // Terms are maximally shared, so the same subterm is typically reachable along many paths. + // Remembering the ones already seen keeps this linear in the size of the term graph rather + // than the size of the tree it unfolds to. Keying on the term address is only valid because + // no terms are created here, so no garbage collection can run during the traversal. + let mut visited = AHashSet::new(); + + let variable: Option<()> = visit_data_expr(&DataExpressionRef::from(term.copy()), (), |expr, context| { + if !visited.insert(expr.index()) { + ControlFlow::Continue(Step::Prune) + } else if is_data_variable(expr) { + ControlFlow::Break(()) + } else { + // A function symbol and a machine number have no children, and the head function + // symbol of an application is closed by definition, so neither needs to be recognised + // separately here. + ControlFlow::Continue(Step::Into(context)) + } + }); + + variable.is_none() +} + +#[cfg(test)] +mod tests { + use ahash::AHashSet; + + use crate::DataExpression; + use crate::is_closed; + + #[test] + fn test_is_closed_ground_term() { + let term = DataExpression::from_string("s(s(a), b)").unwrap(); + assert!(is_closed(&term)); + } + + #[test] + fn test_is_closed_with_variable() { + let variables = AHashSet::from_iter(["x".to_string()]); + let term = DataExpression::from_string_untyped("s(s(x), b)", &variables).unwrap(); + assert!(!is_closed(&term)); + } + + #[test] + fn test_is_closed_bare_variable() { + let variables = AHashSet::from_iter(["x".to_string()]); + let term = DataExpression::from_string_untyped("x", &variables).unwrap(); + assert!(!is_closed(&term)); + } +} diff --git a/crates/data/src/data_expression.rs b/crates/data/src/data_expression.rs index 1b6eab89c..e3e55b33e 100644 --- a/crates/data/src/data_expression.rs +++ b/crates/data/src/data_expression.rs @@ -38,9 +38,7 @@ use crate::is_data_variable; use crate::is_data_where_clause; use crate::is_data_whr_decl; -/// The kind of a binder in a `DataAbstraction` — mirrors mCRL2's -/// `data::binder_type` enum (the 0-arity marker term that is the first child -/// of every `Binder(type, vars, body)` aterm). +/// The kind of a binder in a `DataAbstraction`. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum BinderType { Lambda, @@ -99,6 +97,24 @@ mod inner { } } + /// Same as [DataExpression::data_function_symbol], but returns `None` for a variable + /// instead of panicking. + /// + /// Pattern matching uses this to observe a symbol: a variable in the subject term has no + /// head symbol and therefore matches no pattern position. The variable is only tested + /// after the two cases that do have one, so the common path costs the same. + pub fn try_data_function_symbol(&self) -> Option> { + if is_data_application(&self.term) { + Some(self.term.arg(0).into()) + } else if is_data_function_symbol(&self.term) { + Some(self.term.copy().into()) + } else if is_data_variable(&self.term) { + None + } else { + panic!("try_data_function_symbol not implemented for {self}"); + } + } + /// Returns the data sub-expressions of a data expression. /// - function symbol f -> [] /// - variable x -> [] @@ -622,7 +638,20 @@ mod inner { } } -pub use inner::*; +pub use inner::DataAbstraction; +pub use inner::DataApplication; +pub use inner::DataApplicationRef; +pub use inner::DataEquation; +pub use inner::DataExpression; +pub use inner::DataExpressionRef; +pub use inner::DataFunctionSymbol; +pub use inner::DataFunctionSymbolRef; +pub use inner::DataVariable; +pub use inner::DataVariableRef; +pub use inner::DataWhereClause; +pub use inner::DataWhrDecl; +pub use inner::MachineNumber; +pub use inner::MachineNumberRef; /// Returns the number of leading `ATerm` arguments that are *not* data sub-expressions and /// must therefore be skipped by `data_arguments`, or `None` for binders/where clauses which diff --git a/crates/data/src/data_terms.rs b/crates/data/src/data_terms.rs index 194134a46..d1f883b99 100644 --- a/crates/data/src/data_terms.rs +++ b/crates/data/src/data_terms.rs @@ -15,6 +15,8 @@ thread_local! { /// Defines default symbols and terms for data elements. /// /// These mirror the mCRL2 definitions since that is convenient for loading the mCRL2 binary formats. +/// The set is kept complete on purpose, so some symbols are declared here before anything reads +/// them; those carry an `allow(dead_code)`. /// /// All `Symbol` fields are wrapped in `ManuallyDrop` so that their destructors never run at thread /// exit. @@ -38,19 +40,19 @@ pub(crate) struct DataSymbols { pub fbag_container_symbol: ManuallyDrop, // Data expressions that are abstractions - pub data_binder_symbol: ManuallyDrop, - pub data_lambda_symbol: ManuallyDrop, - pub data_exists_symbol: ManuallyDrop, - pub data_forall_symbol: ManuallyDrop, - pub data_set_comprehension_symbol: ManuallyDrop, - pub data_bag_comprehension_symbol: ManuallyDrop, - pub data_untyped_set_bag_comprehension_symbol: ManuallyDrop, + pub(crate) data_binder_symbol: ManuallyDrop, + pub(crate) data_lambda_symbol: ManuallyDrop, + pub(crate) data_exists_symbol: ManuallyDrop, + pub(crate) data_forall_symbol: ManuallyDrop, + pub(crate) data_set_comprehension_symbol: ManuallyDrop, + pub(crate) data_bag_comprehension_symbol: ManuallyDrop, // Data expressions pub data_function_symbol: ManuallyDrop, pub data_function_symbol_no_index: ManuallyDrop, pub data_variable: ManuallyDrop, pub data_where_clause: ManuallyDrop, + #[allow(dead_code)] pub data_untyped_identifier_clause: ManuallyDrop, /// A data expression, not itself a data expression. @@ -86,7 +88,6 @@ impl DataSymbols { data_forall_symbol: ManuallyDrop::new(Symbol::new("Forall", 0)), data_set_comprehension_symbol: ManuallyDrop::new(Symbol::new("SetComp", 0)), data_bag_comprehension_symbol: ManuallyDrop::new(Symbol::new("BagComp", 0)), - data_untyped_set_bag_comprehension_symbol: ManuallyDrop::new(Symbol::new("UntypedSetBagComp", 0)), data_function_symbol: ManuallyDrop::new(Symbol::new("OpId", 2)), data_function_symbol_no_index: ManuallyDrop::new(Symbol::new("OpIdNoIndex", 2)), diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 088b021f7..f622a5260 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -1,18 +1,29 @@ #![doc = include_str!("../README.md")] #![forbid(unsafe_code)] +mod closed; mod data_expression; mod data_terms; mod machine_word_evaluation; mod mcrl2_data_specification; mod sort_terms; +mod visitor; -pub(crate) use data_terms::*; +// Explicit pub(crate) re-exports. +pub(crate) use data_terms::DATA_SYMBOLS; +pub(crate) use data_terms::is_basic_sort; +pub(crate) use data_terms::is_data_equation; +pub(crate) use data_terms::is_data_expression; +pub(crate) use data_terms::is_data_whr_decl; +pub(crate) use data_terms::is_sort_alias; +pub(crate) use data_terms::is_sort_expression; // Public API +pub use closed::is_closed; pub use data_expression::BinderType; pub use data_expression::DataAbstraction; pub use data_expression::DataApplication; +pub use data_expression::DataApplicationRef; pub use data_expression::DataEquation; pub use data_expression::DataExpression; pub use data_expression::DataExpressionRef; @@ -44,3 +55,7 @@ pub use sort_terms::SortArrow; pub use sort_terms::SortCons; pub use sort_terms::SortExpression; pub use sort_terms::SortExpressionRef; +pub use visitor::ClosureVisitor; +pub use visitor::DataExpressionVisitor; +pub use visitor::try_visit_data_expr; +pub use visitor::visit_data_expr; diff --git a/crates/data/src/sort_terms.rs b/crates/data/src/sort_terms.rs index 86e636352..3b4bad1e3 100644 --- a/crates/data/src/sort_terms.rs +++ b/crates/data/src/sort_terms.rs @@ -245,7 +245,14 @@ mod inner { } } -pub use inner::*; +pub use inner::BasicSort; +pub use inner::BasicSortRef; +pub use inner::ContainerSortKind; +pub use inner::SortAlias; +pub use inner::SortArrow; +pub use inner::SortCons; +pub use inner::SortExpression; +pub use inner::SortExpressionRef; #[cfg(test)] mod tests { diff --git a/crates/data/src/visitor.rs b/crates/data/src/visitor.rs new file mode 100644 index 000000000..c0f7621a6 --- /dev/null +++ b/crates/data/src/visitor.rs @@ -0,0 +1,331 @@ +use std::convert::Infallible; +use std::marker::PhantomData; +use std::ops::ControlFlow; + +use merc_aterm::Term; +use merc_utilities::Step; +use merc_utilities::Visit; + +use crate::DataApplicationRef; +use crate::DataExpressionRef; +use crate::DataFunctionSymbolRef; +use crate::DataVariableRef; +use crate::MachineNumberRef; +use crate::is_data_application; +use crate::is_data_function_symbol; +use crate::is_data_machine_number; +use crate::is_data_variable; + +/// A top-down traversal over data expressions that threads a context down the term. +/// +/// There is one method per node kind, so a visitor only overrides the kinds it cares about and +/// the remaining ones keep descending. Every method returns a [Visit], so a traversal that has +/// found what it was looking for stops without walking the rest of the term, and subterms that +/// cannot contribute are skipped with [Step::Prune]. The context is passed from a node to its +/// children, which lets a visitor track where it is without maintaining a stack of its own. +/// +/// Terms are maximally shared, so the same subterm is typically reachable along many paths. A +/// visitor whose work per node is not trivial should remember the indices of the nodes it has +/// seen and prune the repeats, which keeps the traversal linear in the size of the term graph +/// rather than of the tree it unfolds to. +/// +/// The traversal only inspects terms; [Step::Replace] is uninhabited here because a rewritten +/// term has to be rebuilt bottom-up, which is what `TermBuilder` is for. +/// +/// Only the data expressions that this crate represents are traversed; binders and where clauses +/// make [DataExpressionVisitor::try_visit] panic, as they do everywhere else in this crate. +pub trait DataExpressionVisitor { + /// Threaded from a node to its children. + type Context: Copy; + + /// Produced when the traversal stops early. + type Break; + + type Error; + + /// The returned [Step] is ignored, since a variable has no children. + fn visit_variable( + &mut self, + _variable: &DataVariableRef<'_>, + context: Self::Context, + ) -> Visit { + Ok(ControlFlow::Continue(Step::Into(context))) + } + + /// The returned [Step] is ignored, since a function symbol has no children. + fn visit_function_symbol( + &mut self, + _function_symbol: &DataFunctionSymbolRef<'_>, + context: Self::Context, + ) -> Visit { + Ok(ControlFlow::Continue(Step::Into(context))) + } + + /// The returned [Step] is ignored, since a machine number has no children. + fn visit_machine_number( + &mut self, + _number: &MachineNumberRef<'_>, + context: Self::Context, + ) -> Visit { + Ok(ControlFlow::Continue(Step::Into(context))) + } + + /// The head function symbol is not visited separately; it is only reachable through the + /// application that carries it. + fn visit_application( + &mut self, + _application: &DataApplicationRef<'_>, + context: Self::Context, + ) -> Visit { + Ok(ControlFlow::Continue(Step::Into(context))) + } + + /// Visits `expr` and, unless a visit method breaks or prunes, its subexpressions. + /// + /// This is the only place that recurses. It keeps its own worklist rather than using the call + /// stack, so a term that is deeper than the stack can hold is still traversed. + /// + /// Panics for binders and where clauses, which this crate does not represent. + fn try_visit( + &mut self, + expr: &DataExpressionRef<'_>, + context: Self::Context, + ) -> Result, Self::Error> { + let mut stack: Vec<(DataExpressionRef<'_>, Self::Context)> = vec![(expr.copy(), context)]; + + while let Some((expr, context)) = stack.pop() { + // Recognising an application is the most expensive test, so it is tried last. + let outcome = if is_data_variable(&expr) { + self.visit_variable(&DataVariableRef::from(Term::copy(&expr)), context)? + } else if is_data_function_symbol(&expr) { + self.visit_function_symbol(&DataFunctionSymbolRef::from(Term::copy(&expr)), context)? + } else if is_data_machine_number(&expr) { + self.visit_machine_number(&MachineNumberRef::from(Term::copy(&expr)), context)? + } else if is_data_application(&expr) { + let context = match self.visit_application(&DataApplicationRef::from(Term::copy(&expr)), context)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Step::Prune) => continue, + ControlFlow::Continue(Step::Replace(replacement)) => match replacement {}, + ControlFlow::Continue(Step::Into(context)) => context, + }; + + // The arguments are pushed in reverse so that they are visited left to right. + for index in (0..expr.data_arguments().len()).rev() { + stack.push((expr.data_arg(index), context)); + } + + continue; + } else { + panic!("DataExpressionVisitor is not defined for binders and where clauses: {expr}"); + }; + + if let ControlFlow::Break(result) = outcome { + return Ok(Some(result)); + } + } + + Ok(None) + } + + /// See [DataExpressionVisitor::try_visit], for visitors that cannot fail. + fn visit(&mut self, expr: &DataExpressionRef<'_>, context: Self::Context) -> Option + where + Self: DataExpressionVisitor, + { + match self.try_visit(expr, context) { + Ok(result) => result, + Err(error) => match error {}, + } + } +} + +/// Adapts a closure into a [DataExpressionVisitor] that treats every node kind the same. +pub struct ClosureVisitor { + function: F, + _marker: PhantomData (T, E)>, +} + +impl ClosureVisitor { + pub fn new(function: F) -> Self { + ClosureVisitor { + function, + _marker: PhantomData, + } + } +} + +impl DataExpressionVisitor for ClosureVisitor +where + C: Copy, + F: FnMut(&DataExpressionRef<'_>, C) -> Visit, +{ + type Context = C; + type Break = T; + type Error = E; + + fn visit_variable(&mut self, variable: &DataVariableRef<'_>, context: C) -> Visit { + (self.function)(&DataExpressionRef::from(Term::copy(variable)), context) + } + + fn visit_function_symbol( + &mut self, + function_symbol: &DataFunctionSymbolRef<'_>, + context: C, + ) -> Visit { + (self.function)(&DataExpressionRef::from(Term::copy(function_symbol)), context) + } + + fn visit_machine_number(&mut self, number: &MachineNumberRef<'_>, context: C) -> Visit { + (self.function)(&DataExpressionRef::from(Term::copy(number)), context) + } + + fn visit_application(&mut self, application: &DataApplicationRef<'_>, context: C) -> Visit { + (self.function)(&DataExpressionRef::from(Term::copy(application)), context) + } +} + +/// Visits `expr` and its subexpressions top-down, calling `visitor` on every node. +/// +/// See [DataExpressionVisitor] for the meaning of the context and the return value; implement +/// that trait directly when the node kinds need to be treated differently. +pub fn try_visit_data_expr(expr: &DataExpressionRef<'_>, context: C, visitor: F) -> Result, E> +where + C: Copy, + F: FnMut(&DataExpressionRef<'_>, C) -> Visit, +{ + ClosureVisitor::::new(visitor).try_visit(expr, context) +} + +/// See [try_visit_data_expr], for visitors that cannot fail. +pub fn visit_data_expr(expr: &DataExpressionRef<'_>, context: C, mut visitor: F) -> Option +where + C: Copy, + F: FnMut(&DataExpressionRef<'_>, C) -> ControlFlow>, +{ + match try_visit_data_expr::(expr, context, |expr, context| Ok(visitor(expr, context))) { + Ok(result) => result, + Err(error) => match error {}, + } +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + use std::ops::ControlFlow; + + use ahash::AHashSet; + use merc_aterm::Term; + use merc_utilities::Step; + use merc_utilities::Visit; + + use crate::DataApplicationRef; + use crate::DataExpression; + use crate::DataExpressionRef; + use crate::DataFunctionSymbolRef; + use crate::DataVariableRef; + use crate::visitor::DataExpressionVisitor; + use crate::visitor::visit_data_expr; + + /// Collects the name of every function symbol in the order in which it is visited. + #[derive(Default)] + struct SymbolNames { + names: Vec, + } + + impl DataExpressionVisitor for SymbolNames { + type Context = (); + type Break = Infallible; + type Error = Infallible; + + fn visit_function_symbol( + &mut self, + function_symbol: &DataFunctionSymbolRef<'_>, + context: (), + ) -> Visit { + self.names.push(function_symbol.name().value().to_string()); + Ok(ControlFlow::Continue(Step::Into(context))) + } + + fn visit_application( + &mut self, + application: &DataApplicationRef<'_>, + context: (), + ) -> Visit { + self.names + .push(application.data_function_symbol().name().value().to_string()); + Ok(ControlFlow::Continue(Step::Into(context))) + } + } + + #[test] + fn test_visit_order_is_top_down() { + let term = DataExpression::from_string("f(g(a), b)").unwrap(); + + let mut visitor = SymbolNames::default(); + assert!(visitor.visit(&term.copy(), ()).is_none()); + assert_eq!(visitor.names, ["f", "g", "a", "b"]); + } + + #[test] + fn test_visit_breaks_early() { + let variables = AHashSet::from_iter(["x".to_string()]); + let term = DataExpression::from_string_untyped("f(g(x), h(x))", &variables).unwrap(); + + // Counting the applications visited before the break shows that `h` is never reached. + let mut visited = 0; + let found = visit_data_expr(&term.copy(), (), |expr, context| { + visited += 1; + if crate::is_data_variable(expr) { + ControlFlow::Break(DataVariableRef::from(Term::copy(expr)).protect()) + } else { + ControlFlow::Continue(Step::Into(context)) + } + }); + + assert!(found.is_some_and(|variable| variable.name() == "x")); + assert_eq!(visited, 3, "expected f, g and x to be visited"); + } + + #[test] + fn test_visit_prunes_subterms() { + let term = DataExpression::from_string("f(g(a), b)").unwrap(); + + let mut visited = Vec::new(); + let result: Option = visit_data_expr(&term.copy(), (), |expr, _context| { + visited.push(format!("{expr}")); + + // Everything below `g` is skipped, so `a` is never visited. + if format!("{expr}").starts_with("g") { + ControlFlow::Continue(Step::Prune) + } else { + ControlFlow::Continue(Step::Into(())) + } + }); + + assert!(result.is_none()); + assert_eq!(visited, ["f(g(a), b)", "g(a)", "b"]); + } + + #[test] + fn test_visit_threads_context() { + let term = DataExpression::from_string("f(g(a), b)").unwrap(); + + // The context is the depth of the node, which is one more than its parent's. + let mut depths = Vec::new(); + let result: Option = visit_data_expr(&term.copy(), 0, |expr: &DataExpressionRef<'_>, depth| { + depths.push((format!("{expr}"), depth)); + ControlFlow::Continue(Step::Into(depth + 1)) + }); + + assert!(result.is_none()); + assert_eq!( + depths, + [ + ("f(g(a), b)".to_string(), 0), + ("g(a)".to_string(), 1), + ("a".to_string(), 2), + ("b".to_string(), 1) + ] + ); + } +} diff --git a/crates/explore/src/cache_lps.rs b/crates/explore/src/cache_lps.rs index 9a802a6f4..23dda790b 100644 --- a/crates/explore/src/cache_lps.rs +++ b/crates/explore/src/cache_lps.rs @@ -8,8 +8,10 @@ use merc_utilities::MercError; use merc_utilities::ShardedCounter; use crate::LPS; +use crate::OwnedStateEffect; use crate::SequenceForest; use crate::SequenceForestContext; +use crate::StateEffect; use crate::Summand; use crate::Tree; @@ -67,8 +69,9 @@ pub struct CacheSummandWrapper { /// Positions in the state vector that are read by this summand; these form the cache key. read_positions: Vec, - /// Positions in the state vector that are written by this summand; these are stored in the cache values. - write_positions: Vec, + /// How the inner summand's next states relate to the source state, which + /// decides whether cache values hold write positions or whole vectors. + effect: OwnedStateEffect, /// Caching strategy in effect for this summand. strategy: CachingStrategy, @@ -105,7 +108,7 @@ impl CacheLPS

{ .map(|(i, s)| CacheSummandWrapper { index: i, read_positions: s.read_positions().to_vec(), - write_positions: s.write_positions().to_vec(), + effect: s.effect().to_owned(), strategy, cache: ShardedHashMap::with_shards(CACHE_SHARDS), forest: Arc::clone(&forest), @@ -118,6 +121,12 @@ impl CacheLPS

{ CacheLPS { inner, summands } } + /// Returns the wrapped LPS, so callers can reach capabilities that this + /// wrapper forwards rather than implements. + pub fn inner(&self) -> &P { + &self.inner + } + /// Collects per-summand cache metrics for this [`CacheLPS`]. /// /// The returned [`CacheMetrics`] implements [`fmt::Display`] for a @@ -287,16 +296,17 @@ impl LPS for CacheLPS

{ self.inner.prepare(&mut context.inner, state) } - fn state_info(&self, state: &[Self::Value]) -> Self::StateInfo { - self.inner.state_info(state) + fn state_info(&self, state: &[Self::Value], context: &::Context) -> Self::StateInfo { + self.inner.state_info(state, &context.inner) } } impl CacheSummandWrapper

{ - fn replay_cached( + fn replay_partial( &self, context: &mut CacheContext

, state: &[P::Value], + write_positions: &[usize], results: &[(P::Label, Tree)], report: &mut impl FnMut(&P::Label, &[P::Value]) -> Result<(), MercError>, ) -> Result<(), MercError> { @@ -308,13 +318,28 @@ impl CacheSummandWrapper

{ for (label, write_tree) in results { replay_buf.clear(); replay_buf.extend_from_slice(state); - for (&pos, value) in self.write_positions.iter().zip(self.forest.iter(*write_tree)) { + for (&pos, value) in write_positions.iter().zip(self.forest.iter(*write_tree)) { replay_buf[pos] = value; } report(label, replay_buf)?; } Ok(()) } + + fn replay_full( + &self, + context: &mut CacheContext

, + results: &[(P::Label, Tree)], + report: &mut impl FnMut(&P::Label, &[P::Value]) -> Result<(), MercError>, + ) -> Result<(), MercError> { + let replay_buf = &mut context.replay_buf; + for (label, full_tree) in results { + replay_buf.clear(); + replay_buf.extend(self.forest.iter(*full_tree)); + report(label, replay_buf)?; + } + Ok(()) + } } impl Summand for CacheSummandWrapper

{ @@ -357,8 +382,11 @@ impl Summand for CacheSummandWrapper

{ // Fast path: a present entry is replayed in place under the shard read // lock, without cloning the entry's captured results. - let hit = self.cache.find_with(hash, eq, |entry| { - self.replay_cached(context, state, &entry.results, &mut report) + let hit = self.cache.find_with(hash, eq, |entry| match &self.effect { + OwnedStateEffect::Positions(positions) => { + self.replay_partial(context, state, positions, &entry.results, &mut report) + } + OwnedStateEffect::Opaque => self.replay_full(context, &entry.results, &mut report), }); if let Some(result) = hit { #[cfg(feature = "metrics")] @@ -369,14 +397,14 @@ impl Summand for CacheSummandWrapper

{ #[cfg(feature = "metrics")] self.misses.increment(); - // Cache MISS: delegate to inner summand, capture results. Only the - // values at the write positions are stored; on replay they are - // scattered back onto the live source state (see the hit branch). + // Cache MISS: delegate to inner summand and capture results. An opaque + // effect stores the complete next-state vector; a positional one stores + // only the write-position values, with pass-through from the source. let mut captured: Vec<(P::Label, Tree)> = Vec::new(); { let inner_summand = &self.inner.summands()[self.index]; let forest = &self.forest; - let write_positions = &self.write_positions; + let effect = &self.effect; let CacheContext { key_buf, forest_context, @@ -386,8 +414,19 @@ impl Summand for CacheSummandWrapper

{ inner_summand.enumerate(inner, state, |label, next_state| { key_buf.clear(); - for &pos in write_positions { - key_buf.push(next_state[pos]); + match effect { + OwnedStateEffect::Positions(positions) => { + debug_assert!( + positional_effect_holds(state, next_state, positions), + "summand claims StateEffect::Positions but changed a position outside it, \ + or changed the state length; replaying this from the cache would produce \ + a wrong next state" + ); + for &pos in positions { + key_buf.push(next_state[pos]); + } + } + OwnedStateEffect::Opaque => key_buf.extend_from_slice(next_state), } let tree = forest.insert_with(key_buf, forest_context); captured.push((label.clone(), tree)); @@ -416,7 +455,20 @@ impl Summand for CacheSummandWrapper

{ &self.read_positions } - fn write_positions(&self) -> &[usize] { - &self.write_positions + fn effect(&self) -> StateEffect<'_> { + self.effect.borrow() } } + +/// Checks the [`StateEffect::Positions`] contract for one enumerated transition. +/// +/// Only called from a `debug_assert!`, since it costs `O(state.len())` per +/// transition. A violation is otherwise silent: the transition itself is reported +/// correctly, and only a later cache *hit* replays it onto the wrong source state. +fn positional_effect_holds(state: &[V], next_state: &[V], write_positions: &[usize]) -> bool { + if state.len() != next_state.len() { + return false; + } + + (0..state.len()).all(|pos| write_positions.contains(&pos) || state[pos] == next_state[pos]) +} diff --git a/crates/explore/src/explore.rs b/crates/explore/src/explore.rs index e136449df..da134d26d 100644 --- a/crates/explore/src/explore.rs +++ b/crates/explore/src/explore.rs @@ -1,4 +1,5 @@ use std::collections::VecDeque; +use std::fmt::Debug; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicUsize; @@ -52,6 +53,12 @@ pub enum ExplorationStrategy { /// Returns the [`StateIndex`] assigned to the initial state of `lps`. The /// caller is responsible for finalising any builder it owns; counting states /// and transitions (and any progress reporting) is left to the closures. +/// +/// # Logging +/// +/// At trace level every explored state vector and every enumerated transition +/// (with its summand index and target state vector) is logged, which is only +/// practical for small state spaces. pub fn explore( lps: &P, strategy: ExplorationStrategy, @@ -62,11 +69,15 @@ pub fn explore( ) -> Result where P: LPS, + P::Value: Debug, + P::Label: Debug, OnState: FnMut(&mut Ctx, StateIndex, &P::StateInfo) -> Result<(), MercError>, OnTransition: FnMut(&mut Ctx, StateIndex, &P::Label, StateIndex) -> Result<(), MercError>, { let discovered: DiscoveredSet = DiscoveredSet::new(); - let (initial_ref, _) = discovered.insert(&lps.initial_state()); + let initial_state = lps.initial_state(); + let (initial_ref, _) = discovered.insert(&initial_state); + log::trace!("explore: initial state {} = {initial_state:?}", initial_ref.index()); let mut working: VecDeque = VecDeque::from([initial_ref]); // Reusable buffer holding the current state vector reconstructed from the @@ -93,9 +104,10 @@ where let found = discovered.get_into(current, &mut current_state); debug_assert!(found, "StateRef from working queue must be valid"); let from = StateIndex::new(current.index()); + log::trace!("explore: state {from} = {current_state:?}"); let summands_to_explore = lps.prepare(&mut enumerate_context, ¤t_state); - let info = lps.state_info(¤t_state); + let info = lps.state_info(¤t_state, &enumerate_context); on_state(ctx, from, &info)?; let summands = lps.summands(); @@ -103,6 +115,10 @@ where summands[index].enumerate(&mut enumerate_context, ¤t_state, |label, next_state| { let (target_ref, is_new) = discovered.insert_with(next_state, &mut forest_context); let to = StateIndex::new(target_ref.index()); + log::trace!( + "explore: transition (summand {index}) {from} --{label:?}--> {to} = {next_state:?}{}", + if is_new { " (new)" } else { "" } + ); on_transition(ctx, from, label, to)?; if is_new { working.push_back(target_ref); @@ -136,6 +152,12 @@ where /// discovered set, whose backing store hands each worker a whole block of /// consecutive indices at once to keep index allocation contention-free. This /// means that the returned state indices are not dense. +/// +/// # Logging +/// +/// At trace level every explored state vector and every enumerated transition +/// (with its summand index and target state vector) is logged, tagged with the +/// worker that processed it since workers interleave their output. pub fn explore_parallel( lps: &P, make_local: MakeLocal, @@ -144,7 +166,8 @@ pub fn explore_parallel( ) -> Result<(StateIndex, Vec), MercError> where P: LPS + Sync, - P::Value: Send + Sync, + P::Value: Send + Sync + Debug, + P::Label: Debug, ::Context: Send, Local: Send, MakeLocal: Fn() -> Local + Sync, @@ -152,7 +175,9 @@ where OnTransition: Fn(&mut Local, StateIndex, &P::Label, StateIndex) -> Result<(), MercError> + Sync, { let discovered: DiscoveredSet = DiscoveredSet::new(); - let (initial_ref, _) = discovered.insert(&lps.initial_state()); + let initial_state = lps.initial_state(); + let (initial_ref, _) = discovered.insert(&initial_state); + log::trace!("explore: initial state {} = {initial_state:?}", initial_ref.index()); let num_workers = rayon::current_num_threads().max(1); @@ -220,9 +245,10 @@ where let found = discovered.get_into(state_ref, &mut state_buf); debug_assert!(found, "StateRef from work queue must be valid"); let from = StateIndex::new(state_ref.index()); + log::trace!("explore worker {me}: state {from} = {state_buf:?}"); let summands_to_explore = lps.prepare(&mut context, &state_buf); - let info = lps.state_info(&state_buf); + let info = lps.state_info(&state_buf, &context); on_state(&mut local, from, &info)?; let summands = lps.summands(); @@ -230,6 +256,10 @@ where summands[index].enumerate(&mut context, &state_buf, |label, next_state| { let (target_ref, is_new) = discovered.insert_with(next_state, &mut forest_context); let to = StateIndex::new(target_ref.index()); + log::trace!( + "explore worker {me}: transition (summand {index}) {from} --{label:?}--> {to} = {next_state:?}{}", + if is_new { " (new)" } else { "" } + ); on_transition(&mut local, from, label, to)?; if is_new { successors.push(target_ref); diff --git a/crates/explore/src/lib.rs b/crates/explore/src/lib.rs index f9db4ff96..190983c32 100644 --- a/crates/explore/src/lib.rs +++ b/crates/explore/src/lib.rs @@ -23,6 +23,8 @@ pub use explore::ExplorationStrategy; pub use explore::explore; pub use explore::explore_parallel; pub use lps::LPS; +pub use lps::OwnedStateEffect; +pub use lps::StateEffect; pub use lps::Summand; pub use sequence_forest::Slot; pub use thread_affinity::configure_rayon_thread_pool; diff --git a/crates/explore/src/lps.rs b/crates/explore/src/lps.rs index ce80ada8b..c76a8778e 100644 --- a/crates/explore/src/lps.rs +++ b/crates/explore/src/lps.rs @@ -56,7 +56,11 @@ pub trait LPS { ) -> impl Iterator + 'a; /// Returns the state-level metadata for the given source `state`. - fn state_info(&self, state: &[Self::Value]) -> Self::StateInfo; + /// + /// `context` is the already-prepared enumeration context for this state; + /// implementations that derive metadata from rewriting (e.g. general PBES) + /// store their result in the context during [`LPS::prepare`] and read it here. + fn state_info(&self, state: &[Self::Value], context: &::Context) -> Self::StateInfo; } impl LPS for &P { @@ -85,8 +89,8 @@ impl LPS for &P { (**self).prepare(context, state) } - fn state_info(&self, state: &[Self::Value]) -> Self::StateInfo { - (**self).state_info(state) + fn state_info(&self, state: &[Self::Value], context: &::Context) -> Self::StateInfo { + (**self).state_info(state, context) } } @@ -131,10 +135,70 @@ pub trait Summand { /// the guard or the written values must be listed. fn read_positions(&self) -> &[usize]; - /// Returns the indices into the state vector that this summand may change. - /// Every position *not* in this set is passed through unchanged from the - /// source state to each enumerated next state. + /// Describes how this summand's next states relate to its source state. + /// + /// This is a *correctness* contract, not a hint: [`crate::CacheLPS`] replays + /// cached transitions according to it, so a summand that claims + /// [`StateEffect::Positions`] while violating it yields wrong next states on a + /// cache hit. + fn effect(&self) -> StateEffect<'_>; +} + +/// How a [`Summand`]'s next states relate to the state they were enumerated from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StateEffect<'a> { + /// Every next state has the same length as the source state and agrees with + /// it at every position *outside* this set. /// - /// This is also a *correctness* contract under caching. - fn write_positions(&self) -> &[usize]; + /// Consumers may therefore store just these positions and scatter them onto a + /// live source state, which is what makes enumeration caching and symbolic + /// transition relations possible. + Positions(&'a [usize]), + + /// The next states cannot be described positionally: they may differ from the + /// source state in length, or a position may not mean the same thing in both. + /// + /// A parity game generated from a PBES is the motivating case, where a single + /// summand can emit a full parameter vector, a length-1 sink, or an auxiliary + /// vertex. Consumers that require a positional effect (symbolic exploration) + /// must reject this variant rather than guess. + Opaque, +} + +impl StateEffect<'_> { + /// Returns the written positions, or `None` for [`StateEffect::Opaque`]. + pub fn positions(&self) -> Option<&[usize]> { + match self { + StateEffect::Positions(positions) => Some(positions), + StateEffect::Opaque => None, + } + } + + /// Copies this effect into an owned value. + pub fn to_owned(self) -> OwnedStateEffect { + match self { + StateEffect::Positions(positions) => OwnedStateEffect::Positions(positions.to_vec()), + StateEffect::Opaque => OwnedStateEffect::Opaque, + } + } +} + +/// Owned counterpart of [`StateEffect`], for implementors and wrappers that store +/// their effect rather than borrowing it from somewhere else. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum OwnedStateEffect { + /// See [`StateEffect::Positions`]. + Positions(Vec), + /// See [`StateEffect::Opaque`]. + Opaque, +} + +impl OwnedStateEffect { + /// Borrows this effect for returning from [`Summand::effect`]. + pub fn borrow(&self) -> StateEffect<'_> { + match self { + OwnedStateEffect::Positions(positions) => StateEffect::Positions(positions), + OwnedStateEffect::Opaque => StateEffect::Opaque, + } + } } diff --git a/crates/explore/tests/mock_lps.rs b/crates/explore/tests/mock_lps.rs index 1bad1e4bd..8827750fa 100644 --- a/crates/explore/tests/mock_lps.rs +++ b/crates/explore/tests/mock_lps.rs @@ -1,6 +1,7 @@ #![allow(dead_code)] use merc_explore::LPS; +use merc_explore::StateEffect; use merc_explore::Summand; use merc_utilities::MercError; @@ -158,7 +159,7 @@ impl LPS for MockLps { 0..self.summands.len() } - fn state_info(&self, state: &[usize]) -> State { + fn state_info(&self, state: &[usize], _context: &MockContext) -> State { // Surface the live state vector so tests can map dense indices back to // the concrete vectors the drivers assign them. state.to_vec() @@ -213,7 +214,7 @@ impl Summand for MockSummand { &self.read_positions } - fn write_positions(&self) -> &[usize] { - &self.write_positions + fn effect(&self) -> StateEffect<'_> { + StateEffect::Positions(&self.write_positions) } } diff --git a/crates/rec-tests/tests/rec_tests.rs b/crates/rec-tests/tests/rec_tests.rs index 1156712a2..f44274a15 100644 --- a/crates/rec-tests/tests/rec_tests.rs +++ b/crates/rec-tests/tests/rec_tests.rs @@ -1,8 +1,13 @@ +use ahash::HashMap; +use ahash::HashMapExt; use merc_utilities::test_logger; use test_case::test_case; use merc_aterm::ATerm; +use merc_data::DataApplication; use merc_data::DataExpression; +use merc_data::DataVariable; +use merc_data::is_data_application; use merc_data::to_untyped_data_expression; use merc_rec_tests::load_rec_from_strings; use merc_sabre::InnermostRewriter; @@ -61,6 +66,56 @@ fn check_rewriter( } } +/// Abstracts the arguments of `term` into fresh variables, mapped to their own normal forms. +/// +/// Rewriting the result under that substitution must reach the same normal form as rewriting +/// `term` itself, since the arguments are normalised either way. Returns `None` for terms +/// without arguments, which have nothing to abstract. +fn abstract_arguments( + rewriter: &mut impl RewriteEngine, + term: &DataExpression, +) -> Option<(DataExpression, HashMap)> { + if !is_data_application(term) { + return None; + } + + let mut sigma = HashMap::new(); + let mut arguments = vec![]; + for (index, argument) in term.data_arguments().enumerate() { + // Variables and function symbols are distinct terms, so these names cannot clash with + // the function symbols of the specification. + let name = format!("x{index}"); + let variable = DataVariable::new(name.as_str()); + + sigma.insert(variable.clone(), rewriter.rewrite(&argument.protect())); + arguments.push(DataExpression::from(variable)); + } + + let symbol = term.data_function_symbol().protect(); + Some((DataApplication::with_args(&symbol, &arguments).into(), sigma)) +} + +/// Rewrites every term with `rewriter` under a substitution that supplies its arguments, and +/// compares against the expected results. +fn check_rewriter_with_substitution( + rewriter: &mut impl RewriteEngine, + terms: &[DataExpression], + expected: &[DataExpression], + engine: &str, +) { + for (term, expected_result) in terms.iter().zip(expected) { + let Some((abstracted, sigma)) = abstract_arguments(rewriter, term) else { + continue; + }; + + let result = rewriter.rewrite_with(&abstracted, &sigma); + assert_eq!( + &result, expected_result, + "The {engine} rewrite result under a substitution doesn't match the expected result" + ); + } +} + /// Showcases that the set-automaton rewriter rewrites lazily: for the `lazyif` /// specification it only evaluates the selected branch of an if-then-else, so it /// performs the same number of rewrite steps that a just-in-time (jitty) @@ -206,6 +261,27 @@ fn test_rec_specification_naive(rec_files: Vec<&str>, expected_result: &str) { check_rewriter(&mut NaiveRewriter::new(&spec), &terms, &expected, "naive"); } +/// Pins the substitution-aware path of every engine to its plain one on specifications with +/// conditional rules, where the substituted subterms also end up in the condition sides. The +/// innermost engine consumes the substitution while decomposing the term, whereas the other two +/// eagerly substitute first. +#[cfg_attr(miri, ignore)] +#[test_case(vec![include_str!("../../../examples/REC/rec/check1.rec")], include_str!("snapshot/result_check1.txt") ; "check1")] +#[test_case(vec![include_str!("../../../examples/REC/rec/check2.rec")], include_str!("snapshot/result_check2.txt") ; "check2")] +#[test_case(vec![include_str!("../../../examples/REC/rec/logic3.rec")], include_str!("snapshot/result_logic3.txt") ; "logic3")] +#[test_case(vec![include_str!("../../../examples/REC/rec/searchinconditions.rec")], include_str!("snapshot/result_searchinconditions.txt") ; "searchinconditions")] +#[test_case(vec![include_str!("../../../examples/REC/rec/tautologyhard.rec")], include_str!("snapshot/result_tautologyhard.txt") ; "tautologyhard")] +fn test_rec_specification_substitution(rec_files: Vec<&str>, expected_result: &str) { + test_logger(); + + let (spec, terms) = load_spec(&rec_files); + let expected = parse_expected(&terms, expected_result); + + check_rewriter_with_substitution(&mut InnermostRewriter::new(&spec), &terms, &expected, "innermost"); + check_rewriter_with_substitution(&mut SabreRewriter::new(&spec), &terms, &expected, "sabre"); + check_rewriter_with_substitution(&mut NaiveRewriter::new(&spec), &terms, &expected, "naive"); +} + // These tests are too slow without optimisations. #[cfg_attr(miri, ignore)] #[cfg(not(debug_assertions))] diff --git a/crates/sabre/benchmarks/benches/benchmark_sabre.rs b/crates/sabre/benchmarks/benches/benchmark_sabre.rs index ab331f38b..ae919d9b7 100644 --- a/crates/sabre/benchmarks/benches/benchmark_sabre.rs +++ b/crates/sabre/benchmarks/benches/benchmark_sabre.rs @@ -41,7 +41,7 @@ const CASES: &[(&str, &[&str])] = &[ pub fn criterion_benchmark_set_automaton(c: &mut Criterion) { for (name, rec_files) in CASES { - let (syntax_spec, _) = load_rec_from_strings(&rec_files).unwrap(); + let (syntax_spec, _) = load_rec_from_strings(rec_files).unwrap(); let result = syntax_spec.to_rewrite_spec(); c.bench_function(&format!("set automaton {}", name), |bencher| { diff --git a/crates/sabre/src/innermost_rewriter.rs b/crates/sabre/src/innermost_rewriter.rs index 801aab3d2..76f8333ed 100644 --- a/crates/sabre/src/innermost_rewriter.rs +++ b/crates/sabre/src/innermost_rewriter.rs @@ -5,6 +5,7 @@ use merc_aterm::storage::ThreadTermPool; use merc_data::DataApplication; use merc_data::DataExpression; use merc_data::DataExpressionRef; +use merc_data::DataVariableRef; use merc_data::is_data_machine_number; use crate::RewriteEngine; @@ -21,7 +22,9 @@ use crate::set_automaton::SetAutomaton; use crate::set_automaton::machine_number_symbol; use crate::utilities::Config; use crate::utilities::DataPositionIndexed; +use crate::utilities::EmptySubstitution; use crate::utilities::InnermostStack; +use crate::utilities::RewriteSubstitution; use crate::utilities::TermStack; use crate::utilities::TermStackBuilder; use merc_utilities::debug_trace; @@ -30,6 +33,10 @@ impl RewriteEngine for InnermostRewriter { fn rewrite(&mut self, t: &DataExpression) -> DataExpression { self.rewrite_with_statistics(t).0 } + + fn rewrite_with(&mut self, t: &DataExpression, sigma: &S) -> DataExpression { + self.rewrite_under_with_statistics(t, sigma).0 + } } impl InnermostRewriter { @@ -37,12 +44,22 @@ impl InnermostRewriter { /// [RewritingStatistics] gathered while doing so, most notably the number of /// applied rewrite steps. pub fn rewrite_with_statistics(&mut self, t: &DataExpression) -> (DataExpression, RewritingStatistics) { + self.rewrite_under_with_statistics(t, &EmptySubstitution) + } + + /// Same as [InnermostRewriter::rewrite_with_statistics], but replaces the free variables of + /// `t` according to `sigma`, see [RewriteEngine::rewrite_with]. + pub fn rewrite_under_with_statistics( + &mut self, + t: &DataExpression, + sigma: &S, + ) -> (DataExpression, RewritingStatistics) { let mut stats = RewritingStatistics::default(); debug_trace!("input: {}", t); let result = THREAD_TERM_POOL.with(|tp| { - InnermostRewriter::rewrite_aux(tp, &mut self.stack, &mut self.builder, &mut stats, &self.apma, t) + InnermostRewriter::rewrite_aux(tp, &mut self.stack, &mut self.builder, &mut stats, &self.apma, t, sigma) }); info!( @@ -77,13 +94,19 @@ impl InnermostRewriter { /// and places the result on the given index. /// - Construct(arity, index, result): /// - pub(crate) fn rewrite_aux( + /// Free variables of `input_term` are replaced by their image under `sigma` when they are + /// reached, which is the point where the substitution is consumed: every term that is + /// matched, constructed or used to instantiate a condition afterwards is already + /// substituted. + #[allow(clippy::too_many_arguments)] + pub(crate) fn rewrite_aux( tp: &ThreadTermPool, stack: &mut InnermostStack, builder: &mut TermStackBuilder, stats: &mut RewritingStatistics, automaton: &SetAutomaton, input_term: &DataExpression, + sigma: &S, ) -> DataExpression { stats.recursions += 1; { @@ -116,27 +139,39 @@ impl InnermostRewriter { continue; } - let symbol = term.data_function_symbol(); - let arguments = term.data_arguments(); + if let Some(symbol) = term.try_data_function_symbol() { + let arguments = term.data_arguments(); - // For all the argument we reserve space on the stack. - let top_of_stack = write_terms.len(); - for _ in 0..arguments.len() { - write_terms.push(Default::default()); - } + // For all the argument we reserve space on the stack. + let top_of_stack = write_terms.len(); + for _ in 0..arguments.len() { + write_terms.push(Default::default()); + } - // Safety: symbol is stored in the container on the next line. - let symbol = unsafe { write_configs.protect(&symbol) }; - InnermostStack::add_result(&mut write_configs, symbol.into(), arguments.len(), result); - for (offset, arg) in arguments.into_iter().enumerate() { - InnermostStack::add_rewrite( - &mut write_configs, - &mut write_terms, - arg, - top_of_stack + offset, - ); + // Safety: symbol is stored in the container on the next line. + let symbol = unsafe { write_configs.protect(&symbol) }; + InnermostStack::add_result(&mut write_configs, symbol.into(), arguments.len(), result); + for (offset, arg) in arguments.into_iter().enumerate() { + InnermostStack::add_rewrite( + &mut write_configs, + &mut write_terms, + arg, + top_of_stack + offset, + ); + } + drop(write_configs); + } else { + // A variable has no head symbol to match on. It is replaced by its + // image under sigma, which is assumed to be in normal form, so it + // goes straight into the result slot without being constructed. + // Variables outside the domain of sigma are their own normal form. + let variable: DataVariableRef<'_> = term.copy().into(); + let replacement = sigma.get(&variable).unwrap_or_else(|| term.copy()); + + // Safety: replacement is stored in the container on the same line. + write_terms[result] = Some(unsafe { write_terms.protect(&replacement) }.into()); + drop(write_configs); } - drop(write_configs); } Config::Construct(symbol, arity, index) => { // Take the last arity arguments. @@ -251,7 +286,7 @@ impl InnermostRewriter { loop { let state = &automaton.states()[state_index]; - // Get the symbol at the position state.label + // Get the symbol at the position state.label; a variable there matches no pattern. stats.symbol_comparisons += 1; let pos = t.get_data_position(state.label()); @@ -260,7 +295,7 @@ impl InnermostRewriter { let operation_id = if is_data_machine_number(&pos) { machine_number_symbol().operation_id() } else { - pos.data_function_symbol().operation_id() + pos.try_data_function_symbol()?.operation_id() }; // Get the transition for the label and check if there is a pattern match @@ -299,6 +334,9 @@ impl InnermostRewriter { } /// Checks whether the condition holds for given match announcement. + /// + /// The condition sides are built from the matched subterms, which are already substituted + /// normal forms, so the nested normalisations must not apply the substitution a second time. fn check_conditions( tp: &ThreadTermPool, stack: &mut InnermostStack, @@ -312,8 +350,10 @@ impl InnermostRewriter { let rhs: DataExpression = c.rhs_term_stack.evaluate_with(t, builder); let lhs: DataExpression = c.lhs_term_stack.evaluate_with(t, builder); - let rhs_normal = InnermostRewriter::rewrite_aux(tp, stack, builder, stats, automaton, &rhs); - let lhs_normal = InnermostRewriter::rewrite_aux(tp, stack, builder, stats, automaton, &lhs); + let rhs_normal = + InnermostRewriter::rewrite_aux(tp, stack, builder, stats, automaton, &rhs, &EmptySubstitution); + let lhs_normal = + InnermostRewriter::rewrite_aux(tp, stack, builder, stats, automaton, &lhs, &EmptySubstitution); if (lhs_normal != rhs_normal && c.equality) || (lhs_normal == rhs_normal && !c.equality) { return false; diff --git a/crates/sabre/src/matching/nonlinear.rs b/crates/sabre/src/matching/nonlinear.rs index ac6d51109..b4996bfa5 100644 --- a/crates/sabre/src/matching/nonlinear.rs +++ b/crates/sabre/src/matching/nonlinear.rs @@ -120,8 +120,8 @@ mod tests { assert!( check_equivalence_classes(&expression, &eq), "The equivalence classes are not checked correctly, equivalences: {:?} and term {}", - &eq, - &expression + eq, + expression ); } } diff --git a/crates/sabre/src/naive_rewriter.rs b/crates/sabre/src/naive_rewriter.rs index 716aca0bc..1b29ae958 100644 --- a/crates/sabre/src/naive_rewriter.rs +++ b/crates/sabre/src/naive_rewriter.rs @@ -36,8 +36,12 @@ impl RewriteEngine for NaiveRewriter { impl NaiveRewriter { pub fn new(spec: &RewriteSpecification) -> NaiveRewriter { + // Arguments are normalised before the term is matched, so only root matches are needed. + // The set automaton for all positions has a destination per subterm to explore, which + // [NaiveRewriter::find_match] cannot follow: it walks a single chain of states and would + // cycle through them forever. NaiveRewriter { - apma: SetAutomaton::new(spec, AnnouncementInnermost::new, false), + apma: SetAutomaton::new(spec, AnnouncementInnermost::new, true), } } @@ -48,7 +52,10 @@ impl NaiveRewriter { t: DataExpressionRef<'_>, stats: &mut RewritingStatistics, ) -> DataExpression { - let symbol = t.data_function_symbol(); + // A variable has no head symbol to match on and is its own normal form. + let Some(symbol) = t.try_data_function_symbol() else { + return t.protect(); + }; // Recursively call rewrite_aux on all the subterms. let mut arguments = vec![]; @@ -89,9 +96,9 @@ impl NaiveRewriter { loop { let state = &automaton.states()[state_index]; - // Get the symbol at the position state.label + // Get the symbol at the position state.label; a variable there matches no pattern. let u = t.get_data_position(state.label()); - let symbol = u.data_function_symbol(); + let symbol = u.try_data_function_symbol()?; // Get the transition for the label and check if there is a pattern match { diff --git a/crates/sabre/src/sabre_rewriter.rs b/crates/sabre/src/sabre_rewriter.rs index 615fbf27c..a5e1cc275 100644 --- a/crates/sabre/src/sabre_rewriter.rs +++ b/crates/sabre/src/sabre_rewriter.rs @@ -15,15 +15,32 @@ use crate::set_automaton::SetAutomaton; use crate::utilities::AnnouncementSabre; use crate::utilities::ConfigurationStack; use crate::utilities::DataPositionIndexed; +use crate::utilities::RewriteSubstitution; use crate::utilities::SharedTermStack; use crate::utilities::SideInfo; use crate::utilities::SideInfoType; use crate::utilities::TermStackBuilder; +use crate::utilities::apply_substitution; /// A shared trait for all the rewriters pub trait RewriteEngine { /// Rewrites the given term into normal form. fn rewrite(&mut self, term: &DataExpression) -> DataExpression; + + /// Rewrites the given term into normal form, replacing its free variables according to + /// `sigma`. + /// + /// The range of `sigma` must already be in normal form, since replacements are spliced in + /// without being rewritten again. Substitution happens exactly once per free variable + /// occurrence, but rewrite rules can still fire across the substitution boundary, so + /// `rewrite_with(plus(x, 0), {x -> 5})` yields `5` rather than `plus(5, 0)`. + /// + /// Variables outside the domain of `sigma` are left unchanged; they have no head symbol and + /// therefore match no pattern position. Binders and where clauses are not supported, since + /// their bound variables would need capture-avoiding renaming. + fn rewrite_with(&mut self, term: &DataExpression, sigma: &S) -> DataExpression { + self.rewrite(&apply_substitution(term, sigma)) + } } #[derive(Clone, Copy, Debug, Default)] @@ -129,11 +146,15 @@ impl SabreRewriter { let pos: DataExpressionRef = leaf_term.get_data_position(automaton.states()[leaf_state].label()); - let function_symbol = pos.data_function_symbol(); stats.symbol_comparisons += 1; - // Get the transition belonging to the observed symbol - if let Some(tr) = automaton.get_transition(leaf_state, function_symbol.operation_id()) { + // Get the transition belonging to the observed symbol. A variable + // has no head symbol and therefore matches no pattern position. + let transition = pos + .try_data_function_symbol() + .and_then(|symbol| automaton.get_transition(leaf_state, symbol.operation_id())); + + if let Some(tr) = transition { // Loop over the match announcements of the transition for (announcement, annotation) in &tr.announcements { if annotation.conditions.is_empty() && annotation.equivalence_classes.is_empty() { diff --git a/crates/sabre/src/set_automaton/automaton.rs b/crates/sabre/src/set_automaton/automaton.rs index 1fcd15bec..8760b6095 100644 --- a/crates/sabre/src/set_automaton/automaton.rs +++ b/crates/sabre/src/set_automaton/automaton.rs @@ -1,5 +1,7 @@ use std::collections::VecDeque; +use std::convert::Infallible; use std::fmt::Debug; +use std::ops::ControlFlow; use std::time::Instant; use ahash::HashMap; @@ -9,14 +11,19 @@ use log::log_enabled; use log::trace; use log::warn; use merc_aterm::Term; +use merc_data::DataApplicationRef; use merc_data::DataExpression; use merc_data::DataExpressionRef; +use merc_data::DataExpressionVisitor; use merc_data::DataFunctionSymbol; +use merc_data::DataFunctionSymbolRef; +use merc_data::MachineNumberRef; use merc_data::MachineWordOp; use merc_data::is_data_application; use merc_data::is_data_function_symbol; -use merc_data::is_data_machine_number; use merc_data::is_data_variable; +use merc_utilities::Step; +use merc_utilities::Visit; use rustc_hash::FxHashMap; use smallvec::SmallVec; use smallvec::smallvec; @@ -669,24 +676,57 @@ pub fn is_supported_rule(rule: &Rule) -> bool { /// Finds all data symbols in the term and adds them to the symbol index. fn find_symbols(t: &DataExpressionRef<'_>, symbols: &mut HashMap) { - if is_data_function_symbol(t) { - add_symbol(t.protect().into(), 0, symbols); - } else if is_data_application(t) { - // REC specifications should never contain this so it can be a debug error. - assert!( - is_data_function_symbol(&t.data_function_symbol()), - "Error in term {t}, higher order term rewrite systems are not supported" - ); + /// Collects the head symbol of every node, with the arity it occurs with. + struct FindSymbols<'a> { + symbols: &'a mut HashMap, + } - add_symbol(t.data_function_symbol().protect(), t.data_arguments().len(), symbols); - for arg in t.data_arguments() { - find_symbols(&arg, symbols); + impl DataExpressionVisitor for FindSymbols<'_> { + type Context = (); + type Break = Infallible; + type Error = Infallible; + + fn visit_function_symbol( + &mut self, + function_symbol: &DataFunctionSymbolRef<'_>, + context: (), + ) -> Visit { + add_symbol(function_symbol.protect(), 0, self.symbols); + Ok(ControlFlow::Continue(Step::Into(context))) } - } else if is_data_machine_number(t) { - // A machine number has no function symbol of its own, so it is - // represented by the shared stand-in symbol; see [machine_number_symbol]. - add_symbol(machine_number_symbol(), 0, symbols); - } else if !is_data_variable(t) { - panic!("Unexpected term {t:?}"); + + fn visit_application( + &mut self, + application: &DataApplicationRef<'_>, + context: (), + ) -> Visit { + // REC specifications should never contain this so it can be a debug error. + assert!( + is_data_function_symbol(&application.data_function_symbol()), + "Error in term {application}, higher order term rewrite systems are not supported" + ); + + add_symbol( + application.data_function_symbol().protect(), + application.data_arguments().len(), + self.symbols, + ); + Ok(ControlFlow::Continue(Step::Into(context))) + } + + fn visit_machine_number( + &mut self, + _number: &MachineNumberRef<'_>, + context: (), + ) -> Visit { + // A machine number has no function symbol of its own, so it is + // represented by the shared stand-in symbol; see [machine_number_symbol]. + add_symbol(machine_number_symbol(), 0, self.symbols); + Ok(ControlFlow::Continue(Step::Into(context))) + } + + // Variables carry no symbol. } + + FindSymbols { symbols }.visit(t, ()); } diff --git a/crates/sabre/src/utilities/mod.rs b/crates/sabre/src/utilities/mod.rs index 815b2ee77..ec6eeb8d9 100644 --- a/crates/sabre/src/utilities/mod.rs +++ b/crates/sabre/src/utilities/mod.rs @@ -3,6 +3,7 @@ mod data_position; mod data_substitution; mod innermost_stack; mod position; +mod rewrite_substitution; mod substitution; mod term_stack; @@ -11,5 +12,6 @@ pub use data_position::*; pub use data_substitution::*; pub use innermost_stack::*; pub use position::*; +pub use rewrite_substitution::*; pub use substitution::*; pub use term_stack::*; diff --git a/crates/sabre/src/utilities/rewrite_substitution.rs b/crates/sabre/src/utilities/rewrite_substitution.rs new file mode 100644 index 000000000..4e8bb1fc2 --- /dev/null +++ b/crates/sabre/src/utilities/rewrite_substitution.rs @@ -0,0 +1,171 @@ +#![forbid(unsafe_code)] + +use ahash::HashMap; +use merc_aterm::ATerm; +use merc_aterm::Symbol; +use merc_aterm::Term; +use merc_aterm::TermBuilder; +use merc_aterm::Yield; +use merc_aterm::storage::THREAD_TERM_POOL; +use merc_data::DataExpression; +use merc_data::DataExpressionRef; +use merc_data::DataVariable; +use merc_data::DataVariableRef; +use merc_data::is_closed; +use merc_data::is_data_application; +use merc_data::is_data_function_symbol; +use merc_data::is_data_machine_number; +use merc_data::is_data_variable; + +/// Maps free variables to their replacement while rewriting. +/// +/// Implementations are taken as a generic parameter rather than behind a `dyn`, so that the +/// variable lookup is monomorphised at every call site and [EmptySubstitution] collapses to +/// nothing at all. +pub trait RewriteSubstitution { + /// May only be set when [RewriteSubstitution::get] always returns `None`. + /// + /// Monomorphisation folds this constant away, so a caller that is generic over the + /// substitution can skip work that the identity makes pointless, such as the traversal in + /// [apply_substitution]. + const IS_EMPTY: bool = false; + + /// Returns the replacement for `variable`, or `None` if it is not in the domain, in which + /// case the variable is left unchanged. + /// + /// The returned term must be in normal form; rewriters splice it in without normalising it + /// again. + fn get<'a>(&'a self, variable: &DataVariableRef<'_>) -> Option>; +} + +/// The identity substitution, which leaves every variable unchanged. +#[derive(Clone, Copy, Debug, Default)] +pub struct EmptySubstitution; + +impl RewriteSubstitution for EmptySubstitution { + const IS_EMPTY: bool = true; + + #[inline(always)] + fn get<'a>(&'a self, _variable: &DataVariableRef<'_>) -> Option> { + None + } +} + +impl RewriteSubstitution for HashMap { + fn get<'a>(&'a self, variable: &DataVariableRef<'_>) -> Option> { + // Protecting the key allocates an entry in the protection set on every lookup. mCRL2 + // keys its substitutions on the term pointer instead, which we can adopt by storing the + // variable next to its value under its term index; deferred until it shows up in a + // profile. + HashMap::get(self, &variable.protect()).map(|value| value.copy()) + } +} + +/// Replaces every free variable of `t` by its image under `sigma`. +/// +/// The result is not normalised: rewrite rules can fire across the substitution boundary, so +/// callers must still rewrite the result. Panics for binders and where clauses, whose bound +/// variables would need capture-avoiding renaming. +pub fn apply_substitution(t: &DataExpression, sigma: &S) -> DataExpression { + // A closed term has no free variables, so no substitution can change it. + if S::IS_EMPTY || is_closed(t) { + return t.clone(); + } + + let mut builder = TermBuilder::::new(); + + THREAD_TERM_POOL + .with(|tp| { + builder.evaluate( + tp, + t.copy().protect().into(), + |_tp, args, t| { + if is_data_variable(&t) { + let replacement = sigma.get(&t.copy().into()).map(|value| value.protect().into()); + + Ok(Yield::Term(replacement.unwrap_or(t))) + } else if is_data_function_symbol(&t) || is_data_machine_number(&t) { + Ok(Yield::Term(t)) + } else if is_data_application(&t) { + for arg in t.arguments() { + args.push(arg.protect()); + } + + Ok(Yield::Construct(t.get_head_symbol().protect())) + } else { + panic!("apply_substitution is not defined for binders and where clauses: {t}"); + } + }, + |tp, symbol, args| Ok(tp.create_term_iter(&symbol, args)), + ) + }) + .expect("apply_substitution never fails") + .into() +} + +#[cfg(test)] +mod tests { + use ahash::AHashSet; + use ahash::HashMap; + use ahash::HashMapExt; + use merc_data::DataExpression; + use merc_data::DataVariable; + + use crate::utilities::EmptySubstitution; + use crate::utilities::apply_substitution; + + /// Parses `input` treating every name in `variables` as a variable. + fn term(input: &str, variables: &[&str]) -> DataExpression { + let variables: AHashSet = variables.iter().map(|v| v.to_string()).collect(); + DataExpression::from_string_untyped(input, &variables).unwrap() + } + + /// Builds a substitution mapping each name to the corresponding parsed closed term. + fn substitution(entries: &[(&str, &str)]) -> HashMap { + let mut sigma = HashMap::new(); + for (name, value) in entries { + sigma.insert(DataVariable::new(*name), term(value, &[])); + } + sigma + } + + #[test] + fn test_apply_substitution_replaces_variables() { + let sigma = substitution(&[("x", "a")]); + + assert_eq!( + apply_substitution(&term("f(x, g(x), b)", &["x"]), &sigma), + term("f(a, g(a), b)", &[]) + ); + } + + #[test] + fn test_apply_substitution_bare_variable() { + let sigma = substitution(&[("x", "s(a)")]); + + assert_eq!(apply_substitution(&term("x", &["x"]), &sigma), term("s(a)", &[])); + } + + #[test] + fn test_apply_substitution_leaves_variables_outside_the_domain() { + let sigma = substitution(&[("x", "a")]); + let input = term("f(x, y)", &["x", "y"]); + + assert_eq!(apply_substitution(&input, &sigma), term("f(a, y)", &["y"])); + } + + #[test] + fn test_apply_substitution_preserves_closed_terms() { + let sigma = substitution(&[("x", "a")]); + let input = term("f(b, g(c))", &[]); + + assert_eq!(apply_substitution(&input, &sigma), input); + } + + #[test] + fn test_apply_substitution_empty_is_identity() { + let input = term("f(x, g(y))", &["x", "y"]); + + assert_eq!(apply_substitution(&input, &EmptySubstitution), input); + } +} diff --git a/crates/sabre/tests/rewrite_with.rs b/crates/sabre/tests/rewrite_with.rs new file mode 100644 index 000000000..c8001c9aa --- /dev/null +++ b/crates/sabre/tests/rewrite_with.rs @@ -0,0 +1,230 @@ +//! Tests for rewriting under a substitution, see [merc_sabre::RewriteEngine::rewrite_with]. + +use ahash::AHashSet; +use ahash::HashMap; +use ahash::HashMapExt; + +use merc_data::DataExpression; +use merc_data::DataVariable; +use merc_sabre::Condition; +use merc_sabre::InnermostRewriter; +use merc_sabre::NaiveRewriter; +use merc_sabre::RewriteEngine; +use merc_sabre::RewriteSpecification; +use merc_sabre::Rule; +use merc_sabre::SabreRewriter; +use merc_sabre::test_utility::create_rewrite_rule; +use merc_sabre::utilities::EmptySubstitution; + +/// Parses `input` treating every name in `variables` as a variable. +fn term(input: &str, variables: &[&str]) -> DataExpression { + let variables: AHashSet = variables.iter().map(|v| v.to_string()).collect(); + DataExpression::from_string_untyped(input, &variables).unwrap() +} + +/// Builds a substitution mapping each name to the corresponding parsed closed term. +fn substitution(entries: &[(&str, &str)]) -> HashMap { + let mut sigma = HashMap::new(); + for (name, value) in entries { + sigma.insert(DataVariable::new(*name), term(value, &[])); + } + sigma +} + +/// Asserts that all three engines rewrite `input` under `sigma` to `expected`. +/// +/// The innermost engine consumes the substitution while it decomposes the term, whereas the +/// other two fall back on eagerly substituting first, so this pins the fused path to the simple +/// baseline. +fn assert_all_engines( + spec: &RewriteSpecification, + input: &DataExpression, + sigma: &HashMap, + expected: &DataExpression, +) { + assert_eq!( + &InnermostRewriter::new(spec).rewrite_with(input, sigma), + expected, + "InnermostRewriter result mismatch" + ); + assert_eq!( + &SabreRewriter::new(spec).rewrite_with(input, sigma), + expected, + "SabreRewriter result mismatch" + ); + assert_eq!( + &NaiveRewriter::new(spec).rewrite_with(input, sigma), + expected, + "NaiveRewriter result mismatch" + ); +} + +/// `plus(x, zero) = x` and `plus(x, s(y)) = s(plus(x, y))`, neither of which observes the first +/// argument, so it may hold a free variable. +fn plus_spec() -> RewriteSpecification { + RewriteSpecification::new(vec![ + create_rewrite_rule("plus(x, zero)", "x", &["x", "y"]).unwrap(), + create_rewrite_rule("plus(x, s(y))", "s(plus(x, y))", &["x", "y"]).unwrap(), + ]) +} + +/// The empty substitution must leave the plain rewrite path untouched. +#[test] +fn test_empty_substitution_reproduces_rewrite() { + let spec = plus_spec(); + let input = term("plus(s(zero), s(s(zero)))", &[]); + let expected = term("s(s(s(zero)))", &[]); + + assert_eq!(InnermostRewriter::new(&spec).rewrite(&input), expected); + assert_eq!( + InnermostRewriter::new(&spec).rewrite_with(&input, &EmptySubstitution), + expected + ); + + assert_eq!(SabreRewriter::new(&spec).rewrite(&input), expected); + assert_eq!( + SabreRewriter::new(&spec).rewrite_with(&input, &EmptySubstitution), + expected + ); + + assert_eq!(NaiveRewriter::new(&spec).rewrite(&input), expected); + assert_eq!( + NaiveRewriter::new(&spec).rewrite_with(&input, &EmptySubstitution), + expected + ); +} + +/// A bare variable input is replaced by its image, which is already a normal form. +#[test] +fn test_bare_variable_input() { + let spec = plus_spec(); + + assert_all_engines( + &spec, + &term("x", &["x"]), + &substitution(&[("x", "s(zero)")]), + &term("s(zero)", &[]), + ); +} + +/// `plus(x, zero)` with `x` mapped to `s(zero)` must yield `s(zero)`, not `plus(s(zero), zero)`: +/// the rule fires after the substitution has been applied. +#[test] +fn test_rule_fires_across_substitution_boundary() { + let spec = plus_spec(); + + assert_all_engines( + &spec, + &term("plus(x, zero)", &["x"]), + &substitution(&[("x", "s(zero)")]), + &term("s(zero)", &[]), + ); +} + +/// A variable outside the domain of the substitution is its own normal form, including when it +/// ends up as the result. +#[test] +fn test_variable_outside_domain_is_unchanged() { + let spec = plus_spec(); + + assert_all_engines( + &spec, + &term("plus(y, zero)", &["y"]), + &substitution(&[("x", "s(zero)")]), + &term("y", &["y"]), + ); +} + +/// A variable also blocks a match when it sits at a position a left-hand side observes: it has +/// no head symbol, so no pattern applies and the surrounding term is in normal form. +#[test] +fn test_variable_at_an_observed_position_blocks_the_match() { + let spec = plus_spec(); + + // The second argument decides between the two rules, so a variable there matches neither. + assert_all_engines( + &spec, + &term("plus(zero, y)", &["y"]), + &substitution(&[("x", "s(zero)")]), + &term("plus(zero, y)", &["y"]), + ); +} + +/// Open terms are normal forms for a plain rewrite too, without any substitution involved. +#[test] +fn test_plain_rewrite_leaves_open_terms_alone() { + let spec = plus_spec(); + let input = term("plus(zero, y)", &["y"]); + + assert_eq!(InnermostRewriter::new(&spec).rewrite(&input), input); + assert_eq!(SabreRewriter::new(&spec).rewrite(&input), input); + assert_eq!(NaiveRewriter::new(&spec).rewrite(&input), input); +} + +/// The image of a variable is assumed to be in normal form and is spliced in without being +/// rewritten again, which is observable both in the result and in the number of rewrite steps. +#[test] +fn test_substituted_value_is_not_rewritten() { + let spec = RewriteSpecification::new(vec![ + create_rewrite_rule("f(x)", "g(x)", &["x"]).unwrap(), + create_rewrite_rule("a", "b", &[]).unwrap(), + ]); + + let (substituted, substituted_stats) = InnermostRewriter::new(&spec) + .rewrite_under_with_statistics(&term("f(x)", &["x"]), &substitution(&[("x", "a")])); + assert_eq!(substituted, term("g(a)", &[])); + + // Rewriting the same term with `a` spelled out instead normalises it to `b` first, which + // costs the extra rewrite step that the normal-form assumption saves. + let (rewritten, rewritten_stats) = InnermostRewriter::new(&spec).rewrite_with_statistics(&term("f(a)", &[])); + assert_eq!(rewritten, term("g(b)", &[])); + + assert_eq!(substituted_stats.rewrite_steps, 1); + assert_eq!(rewritten_stats.rewrite_steps, 2); +} + +/// A non-linear pattern whose repeated variable is only equal after substituting. +#[test] +fn test_nonlinear_pattern_with_substituted_variable() { + let spec = RewriteSpecification::new(vec![create_rewrite_rule("eq(x, x)", "t", &["x"]).unwrap()]); + + assert_all_engines( + &spec, + &term("eq(x, a)", &["x"]), + &substitution(&[("x", "a")]), + &term("t", &[]), + ); + + // With a different image the equivalence check fails and the term is already in normal form. + assert_all_engines( + &spec, + &term("eq(x, a)", &["x"]), + &substitution(&[("x", "b")]), + &term("eq(b, a)", &[]), + ); +} + +/// A condition that only holds once the substitution has been applied. +#[test] +fn test_condition_depends_on_substituted_subterm() { + let spec = RewriteSpecification::new(vec![Rule::with_condition( + vec![Condition::new(term("x", &["x"]), term("a", &[]), true)], + term("f(x)", &["x"]), + term("t", &["x"]), + )]); + + assert_all_engines( + &spec, + &term("f(y)", &["y"]), + &substitution(&[("y", "a")]), + &term("t", &[]), + ); + + // The condition fails for a different image, leaving the substituted term in normal form. + assert_all_engines( + &spec, + &term("f(y)", &["y"]), + &substitution(&[("y", "b")]), + &term("f(b)", &[]), + ); +} diff --git a/crates/symbolic/src/symbolic_lps_explore.rs b/crates/symbolic/src/symbolic_lps_explore.rs index a713e01ce..914a8f7ee 100644 --- a/crates/symbolic/src/symbolic_lps_explore.rs +++ b/crates/symbolic/src/symbolic_lps_explore.rs @@ -73,8 +73,18 @@ impl SymbolicLps { // Build one symbolic group per summand. let mut groups = Vec::with_capacity(lps.summands().len()); for (index, summand) in lps.summands().iter().enumerate() { + // A symbolic transition relation is inherently positional: it stores + // only the read and write columns, so there is no encoding for a + // summand whose next states change shape. + let effect = summand.effect(); + let write_positions = effect.positions().ok_or_else(|| { + MercError::from(format!( + "summand {index} has an opaque state effect, which symbolic exploration cannot encode" + )) + })?; + let mut read_indices: Vec = summand.read_positions().iter().map(|&p| p as Value).collect(); - let mut write_indices: Vec = summand.write_positions().iter().map(|&p| p as Value).collect(); + let mut write_indices: Vec = write_positions.iter().map(|&p| p as Value).collect(); // The short-vector encoding requires sorted read/write indices. read_indices.sort_unstable(); write_indices.sort_unstable(); diff --git a/crates/syntax/src/builder.rs b/crates/syntax/src/builder.rs deleted file mode 100644 index 6359dab35..000000000 --- a/crates/syntax/src/builder.rs +++ /dev/null @@ -1,397 +0,0 @@ -use merc_utilities::MercError; - -use crate::Assignment; -use crate::BagElement; -use crate::DataExpr; -use crate::DataExprKind; -use crate::DataExprUpdate; -use crate::RegFrm; -use crate::RegFrmKind; -use crate::SortExpression; -use crate::SortExpressionKind; -use crate::StateFrm; -use crate::StateFrmKind; - -/// Applies the given function recursively to the state formula. -/// -/// The substitution `function` takes a state formula and returns an optional new -/// formula. If it returns `Some(new_formula)`, the substitution is applied and -/// the new formula is returned. If it returns `None`, the substitution is not -/// applied and the function continues to traverse the formula tree. -pub fn apply_statefrm(formula: StateFrm, mut function: F) -> Result -where - F: FnMut(&StateFrm) -> Result, MercError>, -{ - apply_statefrm_rec(formula, &mut function) -} - -/// Applies the given function recursively to the sort expression. -pub fn apply_sort_expression(sort_expr: SortExpression, mut function: F) -> Result -where - F: FnMut(&SortExpression) -> Result, E>, -{ - apply_sort_expression_rec(sort_expr, &mut function) -} - -/// Rebuilds a data expression bottom-up: the subexpressions of every node are -/// mapped first, then `function` is applied to the node with its rebuilt -/// children. The expression returned by `function` is not traversed again, so -/// the mapping always terminates. -pub fn map_data_expr(expr: DataExpr, mut function: F) -> DataExpr -where - F: FnMut(DataExpr) -> DataExpr, -{ - map_data_expr_rec(expr, &mut function) -} - -/// Applies the given `function` recursively to the regular formula. -/// -/// # Details -/// -/// The substitution function is a partial function, where `Some(formula)` -/// indicates that substitution should be applied. -pub fn apply_regular_formula(formula: RegFrm, mut function: F) -> Result -where - F: FnMut(&RegFrm) -> Result, MercError>, -{ - apply_regular_formula_rec(formula, &mut function) -} - -/// See [apply_regular_formula]. -fn apply_regular_formula_rec(formula: RegFrm, apply: &mut F) -> Result -where - F: FnMut(&RegFrm) -> Result, MercError>, -{ - if let Some(formula) = apply(&formula)? { - // A substitution was made, return the new formula. - return Ok(formula); - } - - let span = formula.span.clone(); - match formula.node { - RegFrmKind::Iteration(reg_frm) => { - let new_reg_frm = apply_regular_formula_rec(*reg_frm, apply)?; - Ok(RegFrmKind::Iteration(Box::new(new_reg_frm)).spanned(span)) - } - RegFrmKind::Plus(reg_frm) => { - let new_reg_frm = apply_regular_formula_rec(*reg_frm, apply)?; - Ok(RegFrmKind::Plus(Box::new(new_reg_frm)).spanned(span)) - } - RegFrmKind::Sequence { lhs, rhs } => { - let new_lhs = apply_regular_formula_rec(*lhs, apply)?; - let new_rhs = apply_regular_formula_rec(*rhs, apply)?; - Ok(RegFrmKind::Sequence { - lhs: Box::new(new_lhs), - rhs: Box::new(new_rhs), - } - .spanned(span)) - } - RegFrmKind::Choice { lhs, rhs } => { - let new_lhs = apply_regular_formula_rec(*lhs, apply)?; - let new_rhs = apply_regular_formula_rec(*rhs, apply)?; - Ok(RegFrmKind::Choice { - lhs: Box::new(new_lhs), - rhs: Box::new(new_rhs), - } - .spanned(span)) - } - other => Ok(other.spanned(span)), - } -} - -/// See [`apply_statefrm`]. -fn apply_statefrm_rec(formula: StateFrm, apply: &mut F) -> Result -where - F: FnMut(&StateFrm) -> Result, MercError>, -{ - if let Some(formula) = apply(&formula)? { - // A substitution was made, return the new formula. - return Ok(formula); - } - - let span = formula.span.clone(); - match formula.node { - StateFrmKind::Binary { op, lhs, rhs } => { - let new_lhs = apply_statefrm_rec(*lhs, apply)?; - let new_rhs = apply_statefrm_rec(*rhs, apply)?; - Ok(StateFrmKind::Binary { - op, - lhs: Box::new(new_lhs), - rhs: Box::new(new_rhs), - } - .spanned(span)) - } - StateFrmKind::FixedPoint { - operator, - variable, - body, - } => { - let new_body = apply_statefrm_rec(*body, apply)?; - Ok(StateFrmKind::FixedPoint { - operator, - variable, - body: Box::new(new_body), - } - .spanned(span)) - } - StateFrmKind::Bound { bound, variables, body } => { - let new_body = apply_statefrm_rec(*body, apply)?; - Ok(StateFrmKind::Bound { - bound, - variables, - body: Box::new(new_body), - } - .spanned(span)) - } - StateFrmKind::Modality { - operator, - formula, - expr, - } => { - let expr = apply_statefrm_rec(*expr, apply)?; - Ok(StateFrmKind::Modality { - operator, - formula, - expr: Box::new(expr), - } - .spanned(span)) - } - StateFrmKind::Quantifier { - quantifier, - variables, - body, - } => { - let new_body = apply_statefrm_rec(*body, apply)?; - Ok(StateFrmKind::Quantifier { - quantifier, - variables, - body: Box::new(new_body), - } - .spanned(span)) - } - StateFrmKind::DataValExprRightMult(expr, data_val) => { - let new_expr = apply_statefrm_rec(*expr, apply)?; - Ok(StateFrmKind::DataValExprRightMult(Box::new(new_expr), data_val).spanned(span)) - } - StateFrmKind::DataValExprLeftMult(data_val, expr) => { - let new_expr = apply_statefrm_rec(*expr, apply)?; - Ok(StateFrmKind::DataValExprLeftMult(data_val, Box::new(new_expr)).spanned(span)) - } - StateFrmKind::Unary { op, expr } => { - let new_expr = apply_statefrm_rec(*expr, apply)?; - Ok(StateFrmKind::Unary { - op, - expr: Box::new(new_expr), - } - .spanned(span)) - } - other @ (StateFrmKind::Id(_, _) - | StateFrmKind::True - | StateFrmKind::False - | StateFrmKind::Delay(_) - | StateFrmKind::Yaled(_) - | StateFrmKind::DataValExpr(_)) => Ok(other.spanned(span)), - } -} - -/// See [`map_data_expr`]. -fn map_data_expr_rec(expr: DataExpr, apply: &mut F) -> DataExpr -where - F: FnMut(DataExpr) -> DataExpr, -{ - let DataExpr { node, span } = expr; - let kind = match node { - DataExprKind::Application { function, arguments } => DataExprKind::Application { - function: Box::new(map_data_expr_rec(*function, apply)), - arguments: arguments - .into_iter() - .map(|argument| map_data_expr_rec(argument, apply)) - .collect(), - }, - DataExprKind::List(elements) => DataExprKind::List( - elements - .into_iter() - .map(|element| map_data_expr_rec(element, apply)) - .collect(), - ), - DataExprKind::Set(elements) => DataExprKind::Set( - elements - .into_iter() - .map(|element| map_data_expr_rec(element, apply)) - .collect(), - ), - DataExprKind::Bag(elements) => DataExprKind::Bag( - elements - .into_iter() - .map(|element| BagElement { - expr: map_data_expr_rec(element.expr, apply), - multiplicity: map_data_expr_rec(element.multiplicity, apply), - }) - .collect(), - ), - DataExprKind::SetBagComp { variable, predicate } => DataExprKind::SetBagComp { - variable, - predicate: Box::new(map_data_expr_rec(*predicate, apply)), - }, - DataExprKind::Lambda { variables, body } => DataExprKind::Lambda { - variables, - body: Box::new(map_data_expr_rec(*body, apply)), - }, - DataExprKind::Quantifier { op, variables, body } => DataExprKind::Quantifier { - op, - variables, - body: Box::new(map_data_expr_rec(*body, apply)), - }, - DataExprKind::Unary { op, expr } => DataExprKind::Unary { - op, - expr: Box::new(map_data_expr_rec(*expr, apply)), - }, - DataExprKind::Binary { op, lhs, rhs } => DataExprKind::Binary { - op, - lhs: Box::new(map_data_expr_rec(*lhs, apply)), - rhs: Box::new(map_data_expr_rec(*rhs, apply)), - }, - DataExprKind::FunctionUpdate { expr, update } => DataExprKind::FunctionUpdate { - expr: Box::new(map_data_expr_rec(*expr, apply)), - update: Box::new(DataExprUpdate { - expr: map_data_expr_rec(update.expr, apply), - update: map_data_expr_rec(update.update, apply), - }), - }, - DataExprKind::Whr { expr, assignments } => DataExprKind::Whr { - expr: Box::new(map_data_expr_rec(*expr, apply)), - assignments: assignments - .into_iter() - .map(|assignment| Assignment { - identifier: assignment.identifier, - expr: map_data_expr_rec(assignment.expr, apply), - }) - .collect(), - }, - leaf @ (DataExprKind::Id(_) - | DataExprKind::Number(_) - | DataExprKind::Bool(_) - | DataExprKind::EmptyList - | DataExprKind::EmptySet - | DataExprKind::EmptyBag) => leaf, - }; - - apply(kind.spanned(span)) -} - -/// See [`apply_sort_expression`]. -fn apply_sort_expression_rec(sort_expr: SortExpression, apply: &mut F) -> Result -where - F: FnMut(&SortExpression) -> Result, E>, -{ - if let Some(sort_expr) = apply(&sort_expr)? { - // A substitution was made, return the new sort expression. - return Ok(sort_expr); - } - - let span = sort_expr.span.clone(); - match sort_expr.node { - SortExpressionKind::Product { lhs, rhs } => { - let lhs = apply_sort_expression_rec(*lhs, apply)?; - let rhs = apply_sort_expression_rec(*rhs, apply)?; - Ok(SortExpressionKind::Product { - lhs: Box::new(lhs), - rhs: Box::new(rhs), - } - .spanned(span)) - } - SortExpressionKind::Function { domain, range } => { - let domain = apply_sort_expression_rec(*domain, apply)?; - let range = apply_sort_expression_rec(*range, apply)?; - Ok(SortExpressionKind::Function { - domain: Box::new(domain), - range: Box::new(range), - } - .spanned(span)) - } - SortExpressionKind::Struct { mut inner } => { - for decl in &mut inner { - for (_, sort) in &mut decl.args { - *sort = apply_sort_expression_rec(sort.clone(), apply)?; - } - } - - Ok(SortExpressionKind::Struct { inner }.spanned(span)) - } - SortExpressionKind::Complex(complex_sort, sort_expression) => { - let inner = apply_sort_expression_rec(*sort_expression, apply)?; - Ok(SortExpressionKind::Complex(complex_sort, Box::new(inner)).spanned(span)) - } - SortExpressionKind::FlattenedFunction { domain, range } => { - let domain = domain - .into_iter() - .map(|sort| apply_sort_expression_rec(sort, apply)) - .collect::, _>>()?; - let range = apply_sort_expression_rec(*range, apply)?; - Ok(SortExpressionKind::FlattenedFunction { - domain, - range: Box::new(range), - } - .spanned(span)) - } - other @ (SortExpressionKind::Reference(_) - | SortExpressionKind::Simple(_) - | SortExpressionKind::Resolved(_, _)) => { - // Ignored - Ok(other.spanned(span)) - } - } -} - -#[cfg(test)] -mod tests { - use std::vec; - - use crate::DataExpr; - use crate::DataExprBinaryOp; - use crate::DataExprKind; - use crate::StateFrmKind; - use crate::UntypedStateFrmSpec; - - use super::apply_statefrm; - use super::map_data_expr; - - #[test] - fn test_visit_state_frm_variables() { - let input = UntypedStateFrmSpec::parse("mu X. [a]X && mu X. X && Y").unwrap(); - - let mut variables = vec![]; - apply_statefrm(input.formula, |frm| { - if let StateFrmKind::Id(name, _) = &frm.node { - variables.push(name.clone()); - } - - Ok(None) - }) - .unwrap(); - - assert_eq!(variables, vec!["X", "X", "Y"]); - } - - /// Children are mapped before their parent: rewriting the addition to its - /// left operand yields the already-mapped operand. - #[test] - fn test_map_data_expr_maps_bottom_up() { - let expr = DataExpr::parse("x + z").unwrap(); - - let mapped = map_data_expr(expr, |expr| { - let DataExpr { node, span } = expr; - match node { - DataExprKind::Id(name) if name == "x" => DataExprKind::Number("1".to_string()).into(), - DataExprKind::Binary { - op: DataExprBinaryOp::Add, - lhs, - rhs: _, - } => *lhs, - other => other.spanned(span), - } - }); - - assert_eq!(mapped, DataExprKind::Number("1".to_string()).into()); - } -} diff --git a/crates/syntax/src/lib.rs b/crates/syntax/src/lib.rs index 0553c0909..723600a17 100644 --- a/crates/syntax/src/lib.rs +++ b/crates/syntax/src/lib.rs @@ -1,7 +1,6 @@ #![doc = include_str!("../README.md")] #![forbid(unsafe_code)] -mod builder; mod consume; mod counterexample_formula; mod parse; @@ -12,15 +11,12 @@ pub mod random_pbes; mod spanned; mod syntax_tree; mod syntax_tree_display; -mod visitor; +mod traverse; pub(crate) use consume::*; pub(crate) use precedence::*; pub(crate) use syntax_tree::*; -pub use builder::apply_sort_expression; -pub use builder::apply_statefrm; -pub use builder::map_data_expr; pub use counterexample_formula::generate_distinguishing_formula; pub use counterexample_formula::generate_refinement_formula; pub use parse::Mcrl2Parser; @@ -83,11 +79,5 @@ pub use syntax_tree::UntypedPres; pub use syntax_tree::UntypedProcessSpecification; pub use syntax_tree::UntypedStateFrmSpec; pub use syntax_tree_display::line_column; -pub use visitor::SortDescend; -pub use visitor::try_visit_data_expr_mut; -pub use visitor::try_visit_sort_expr_with; -pub use visitor::visit_action_formula; -pub use visitor::visit_data_expr; -pub use visitor::visit_regular_formula; -pub use visitor::visit_sort_expr; -pub use visitor::visit_statefrm; +pub use traverse::Recursion; +pub use traverse::Traverse; diff --git a/crates/syntax/src/traverse.rs b/crates/syntax/src/traverse.rs new file mode 100644 index 000000000..d34ae9e1e --- /dev/null +++ b/crates/syntax/src/traverse.rs @@ -0,0 +1,882 @@ +use std::convert::Infallible; +use std::ops::ControlFlow; + +use merc_utilities::Step; +use merc_utilities::Visit; + +use crate::ActFrm; +use crate::ActFrmKind; +use crate::Assignment; +use crate::BagElement; +use crate::ConstructorDecl; +use crate::DataExpr; +use crate::DataExprKind; +use crate::DataExprUpdate; +use crate::PbesExpr; +use crate::PbesExprKind; +use crate::PresExpr; +use crate::PresExprKind; +use crate::ProcessExpr; +use crate::ProcessExprKind; +use crate::RegFrm; +use crate::RegFrmKind; +use crate::SortExpression; +use crate::SortExpressionKind; +use crate::StateFrm; +use crate::StateFrmKind; + +/// The outcome of descending into a subtree: `Continue(())` when the whole subtree was traversed, +/// and `Break(Ok(value))` / `Break(Err(error))` when the traversal stopped early. +/// +/// Both interruptions are carried in the break arm so that a recursive step can propagate them +/// with a single `?`, which is what keeps the generated recursion free of the per-child +/// `if let Some(result) = ... { return }` boilerplate that a hand-written traversal repeats once +/// per variant. +pub type Recursion = ControlFlow, ()>; + +/// A syntax tree node whose children are of its own type, traversed top-down. +/// +/// The traversal is defined once per node type by [Traverse::visit_children] and +/// [Traverse::apply_children], which perform a single recursive step and nothing else; deciding +/// what to do with a node is entirely up to the callback, which pattern matches on it. Everything +/// else — early exit, errors, context threading, substitution — is provided here and is therefore +/// identical for every node type. +/// +/// The traversal never crosses into a *different* node type: a state formula traversal does not +/// descend into the regular formula of a modality, nor into data expressions. Nest the traversals +/// explicitly when that is wanted, so that each callback keeps a single node type. +pub trait Traverse: Sized { + /// Descends into each direct child of this node, in the order in which they are written. + /// + /// This is the only part of the traversal that knows the shape of the node, and the only part + /// that recurses, which is where an explicit worklist would replace the call stack. + fn visit_children(&self, context: C, function: &mut F) -> Recursion + where + C: Copy, + F: FnMut(&Self, C) -> Visit; + + /// See [Traverse::visit_children]; this variant lets the callback replace nodes in place. + fn apply_children(&mut self, context: C, function: &mut F) -> Recursion + where + C: Copy, + F: FnMut(&Self, C) -> Visit; + + /// See [Traverse::apply_children]; this variant rewrites each child bottom-up. + fn transform_children(&mut self, function: &mut F) -> Result<(), E> + where + F: FnMut(&mut Self) -> Result<(), E>; + + /// Visits this node and then, unless the callback breaks or prunes, its children. + fn visit_subtree(&self, context: C, function: &mut F) -> Recursion + where + C: Copy, + F: FnMut(&Self, C) -> Visit, + { + let context = match function(self, context) { + Err(error) => return ControlFlow::Break(Err(error)), + Ok(ControlFlow::Break(value)) => return ControlFlow::Break(Ok(value)), + Ok(ControlFlow::Continue(Step::Prune)) => return ControlFlow::Continue(()), + // `Step::Replace` is uninhabited here, which is how a read-only traversal rules + // substitution out without a second callback type. + Ok(ControlFlow::Continue(Step::Replace(replacement))) => match replacement {}, + Ok(ControlFlow::Continue(Step::Into(context))) => context, + }; + + self.visit_children(context, function) + } + + /// See [Traverse::visit_subtree]; a replaced node is not descended into. + fn apply_subtree(&mut self, context: C, function: &mut F) -> Recursion + where + C: Copy, + F: FnMut(&Self, C) -> Visit, + { + let context = match function(self, context) { + Err(error) => return ControlFlow::Break(Err(error)), + Ok(ControlFlow::Break(value)) => return ControlFlow::Break(Ok(value)), + Ok(ControlFlow::Continue(Step::Prune)) => return ControlFlow::Continue(()), + Ok(ControlFlow::Continue(Step::Replace(replacement))) => { + *self = replacement; + return ControlFlow::Continue(()); + } + Ok(ControlFlow::Continue(Step::Into(context))) => context, + }; + + self.apply_children(context, function) + } + + /// Visits this node and its subtree top-down, threading `context` from a node to its children. + /// + /// Returns the value the callback broke with, or `None` when the whole subtree was visited. + fn visit_with(&self, context: C, mut function: F) -> Result, E> + where + C: Copy, + F: FnMut(&Self, C) -> Visit, + { + match self.visit_subtree(context, &mut function) { + ControlFlow::Break(Ok(value)) => Ok(Some(value)), + ControlFlow::Break(Err(error)) => Err(error), + ControlFlow::Continue(()) => Ok(None), + } + } + + /// See [Traverse::visit_with], for callbacks that need neither a context nor pruning. + fn try_visit(&self, mut function: F) -> Result, E> + where + F: FnMut(&Self) -> Result, E>, + { + self.visit_with((), |node, context| { + Ok(match function(node)? { + ControlFlow::Break(value) => ControlFlow::Break(value), + ControlFlow::Continue(()) => ControlFlow::Continue(Step::Into(context)), + }) + }) + } + + /// See [Traverse::try_visit], for callbacks that cannot fail. + fn visit(&self, mut function: F) -> Option + where + F: FnMut(&Self) -> ControlFlow, + { + match self.try_visit::(|node| Ok(function(node))) { + Ok(result) => result, + Err(error) => match error {}, + } + } + + /// Rewrites this node and its subtree top-down, threading `context` from a node to its + /// children. + /// + /// Returns the value the callback broke with, in which case the tree is left partially + /// rewritten. + fn apply_with(&mut self, context: C, mut function: F) -> Result, E> + where + C: Copy, + F: FnMut(&Self, C) -> Visit, + { + match self.apply_subtree(context, &mut function) { + ControlFlow::Break(Ok(value)) => Ok(Some(value)), + ControlFlow::Break(Err(error)) => Err(error), + ControlFlow::Continue(()) => Ok(None), + } + } + + /// Replaces every node for which `function` returns `Some(replacement)`, in place. + /// + /// A replacement is not descended into, so a callback that rewrites a node into a tree + /// containing that same node terminates. + fn apply_mut(&mut self, mut function: F) -> Result<(), E> + where + F: FnMut(&Self) -> Result, E>, + { + let broken = self.apply_with::<(), Infallible, E, _>((), |node, context| { + Ok(ControlFlow::Continue(match function(node)? { + Some(replacement) => Step::Replace(replacement), + None => Step::Into(context), + })) + })?; + + match broken { + Some(value) => match value {}, + None => Ok(()), + } + } + + /// See [Traverse::apply_mut], for callers that own the node. + fn apply(mut self, function: F) -> Result + where + F: FnMut(&Self) -> Result, E>, + { + self.apply_mut(function)?; + Ok(self) + } + + /// Rewrites this node and its subtree *bottom-up*: the children of a node are rewritten before + /// the node itself, so the callback always sees a node whose children are final. + /// + /// This is the counterpart of [Traverse::apply_mut], which rewrites *top-down* and therefore + /// hands the callback a node whose children are still the original ones. Rewriting a node into + /// a tree that contains that same node terminates here too, since every node is handed to the + /// callback exactly once. The callback rewrites through `&mut`, so nothing is cloned; take the + /// node apart with [std::mem::replace] when its parts have to be moved into the replacement. + fn try_transform(&mut self, function: &mut F) -> Result<(), E> + where + F: FnMut(&mut Self) -> Result<(), E>, + { + self.transform_children(function)?; + function(self) + } + + /// See [Traverse::try_transform], for callbacks that cannot fail. + fn transform(&mut self, mut function: F) + where + F: FnMut(&mut Self), + { + match self.try_transform::(&mut |node| Ok(function(node))) { + Ok(()) => {} + Err(error) => match error {}, + } + } +} + +/// Implements [Traverse] for a node type from a description of its children. +/// +/// Every node type is a [crate::Spanned] wrapper around a `Kind` enum, so the match arms are +/// written against the kind and the span is carried along untouched. +/// +/// The description is a list of match arms that call `recurse` on every child of the node. It is +/// used for both the shared and the mutable recursion, so it must be spelled in a way that is +/// valid under both: bind children through match ergonomics and destructure nested structs with +/// `let`, never through `&x.field` or `&mut x.field`. +/// +/// `Box` fields are the one thing match ergonomics cannot see through. An arm that has to +/// dereference a box therefore has to be written twice, once in each of the optional +/// `shared_only` and `mut_only` sections; the compiler still checks that each of the two +/// resulting matches is exhaustive. +macro_rules! define_traversal { + ( + node: $Node:ty, + children: |$recurse:ident| { $($child:tt)* }, + ) => { + define_traversal! { + node: $Node, + children: |$recurse| { $($child)* }, + shared_only: {}, + mut_only: {}, + } + }; + ( + node: $Node:ty, + children: |$recurse:ident| { $($child:tt)* }, + shared_only: { $($shared_child:tt)* }, + mut_only: { $($mut_child:tt)* }, + ) => { + impl Traverse for $Node { + fn visit_children(&self, context: C, function: &mut F) -> Recursion + where + C: Copy, + F: FnMut(&Self, C) -> Visit, + { + let mut $recurse = |child: &$Node| child.visit_subtree(context, function); + + match &self.node { + $($child)* + $($shared_child)* + } + + ControlFlow::Continue(()) + } + + fn apply_children(&mut self, context: C, function: &mut F) -> Recursion + where + C: Copy, + F: FnMut(&Self, C) -> Visit, + { + let mut $recurse = |child: &mut $Node| child.apply_subtree(context, function); + + match &mut self.node { + $($child)* + $($mut_child)* + } + + ControlFlow::Continue(()) + } + + fn transform_children(&mut self, function: &mut F) -> Result<(), E> + where + F: FnMut(&mut Self) -> Result<(), E>, + { + let mut $recurse = |child: &mut $Node| child.try_transform(function); + + match &mut self.node { + $($child)* + $($mut_child)* + } + + Ok(()) + } + } + }; +} + +define_traversal! { + node: SortExpression, + children: |recurse| { + SortExpressionKind::Product { lhs, rhs } => { + recurse(lhs)?; + recurse(rhs)?; + } + SortExpressionKind::Function { domain, range } => { + recurse(domain)?; + recurse(range)?; + } + SortExpressionKind::FlattenedFunction { domain, range } => { + for sort in domain { + recurse(sort)?; + } + recurse(range)?; + } + SortExpressionKind::Struct { inner } => { + for constructor in inner { + let ConstructorDecl { args, .. } = constructor; + for (_name, sort) in args { + recurse(sort)?; + } + } + } + SortExpressionKind::Complex(_complex_sort, sort) => { + recurse(sort)?; + } + SortExpressionKind::Reference(_) | SortExpressionKind::Simple(_) | SortExpressionKind::Resolved(_, _) => {} + }, +} + +define_traversal! { + node: DataExpr, + children: |recurse| { + DataExprKind::Application { function, arguments } => { + recurse(function)?; + for argument in arguments { + recurse(argument)?; + } + } + DataExprKind::List(exprs) | DataExprKind::Set(exprs) => { + for expr in exprs { + recurse(expr)?; + } + } + DataExprKind::Bag(elements) => { + for element in elements { + let BagElement { expr, multiplicity } = element; + recurse(expr)?; + recurse(multiplicity)?; + } + } + DataExprKind::SetBagComp { predicate, .. } => { + recurse(predicate)?; + } + DataExprKind::Lambda { body, .. } | DataExprKind::Quantifier { body, .. } => { + recurse(body)?; + } + DataExprKind::Unary { expr, .. } => { + recurse(expr)?; + } + DataExprKind::Binary { lhs, rhs, .. } => { + recurse(lhs)?; + recurse(rhs)?; + } + DataExprKind::Whr { expr, assignments } => { + recurse(expr)?; + for assignment in assignments { + let Assignment { expr: value, .. } = assignment; + recurse(value)?; + } + } + DataExprKind::Id(_) + | DataExprKind::Number(_) + | DataExprKind::Bool(_) + | DataExprKind::EmptyList + | DataExprKind::EmptySet + | DataExprKind::EmptyBag => {} + }, + // The update of a function update sits behind a `Box`, which match ergonomics do not see + // through, so its two children have to be reached by an explicit dereference. + shared_only: { + DataExprKind::FunctionUpdate { expr, update } => { + recurse(expr)?; + let DataExprUpdate { expr: index, update: value } = &**update; + recurse(index)?; + recurse(value)?; + } + }, + mut_only: { + DataExprKind::FunctionUpdate { expr, update } => { + recurse(expr)?; + let DataExprUpdate { expr: index, update: value } = &mut **update; + recurse(index)?; + recurse(value)?; + } + }, +} + +define_traversal! { + node: ProcessExpr, + children: |recurse| { + ProcessExprKind::Sum { operand, .. } + | ProcessExprKind::Dist { operand, .. } + | ProcessExprKind::Hide { operand, .. } + | ProcessExprKind::Rename { operand, .. } + | ProcessExprKind::Allow { operand, .. } + | ProcessExprKind::Block { operand, .. } + | ProcessExprKind::Comm { operand, .. } => { + recurse(operand)?; + } + ProcessExprKind::Binary { lhs, rhs, .. } => { + recurse(lhs)?; + recurse(rhs)?; + } + ProcessExprKind::Condition { then, else_, .. } => { + recurse(then)?; + if let Some(operand) = else_ { + recurse(operand)?; + } + } + ProcessExprKind::At { expr, .. } => { + recurse(expr)?; + } + ProcessExprKind::Id(_, _) + | ProcessExprKind::Action(_, _) + | ProcessExprKind::Delta + | ProcessExprKind::Tau => {} + }, +} + +define_traversal! { + node: StateFrm, + children: |recurse| { + StateFrmKind::Binary { lhs, rhs, .. } => { + recurse(lhs)?; + recurse(rhs)?; + } + StateFrmKind::Unary { expr, .. } | StateFrmKind::Modality { expr, .. } => { + recurse(expr)?; + } + StateFrmKind::FixedPoint { body, .. } + | StateFrmKind::Bound { body, .. } + | StateFrmKind::Quantifier { body, .. } => { + recurse(body)?; + } + StateFrmKind::DataValExprRightMult(expr, _data_val) => { + recurse(expr)?; + } + StateFrmKind::DataValExprLeftMult(_data_val, expr) => { + recurse(expr)?; + } + StateFrmKind::True + | StateFrmKind::False + | StateFrmKind::Delay(_) + | StateFrmKind::Yaled(_) + | StateFrmKind::Id(_, _) + | StateFrmKind::DataValExpr(_) => {} + }, +} + +define_traversal! { + node: RegFrm, + children: |recurse| { + RegFrmKind::Iteration(inner) | RegFrmKind::Plus(inner) => { + recurse(inner)?; + } + RegFrmKind::Sequence { lhs, rhs } | RegFrmKind::Choice { lhs, rhs } => { + recurse(lhs)?; + recurse(rhs)?; + } + RegFrmKind::Action(_act_frm) => {} + }, +} + +define_traversal! { + node: ActFrm, + children: |recurse| { + ActFrmKind::Negation(inner) => { + recurse(inner)?; + } + ActFrmKind::Quantifier { body, .. } => { + recurse(body)?; + } + ActFrmKind::Binary { lhs, rhs, .. } => { + recurse(lhs)?; + recurse(rhs)?; + } + ActFrmKind::True | ActFrmKind::False | ActFrmKind::MultAct(_) | ActFrmKind::DataExprVal(_) => {} + }, +} + +define_traversal! { + node: PbesExpr, + children: |recurse| { + PbesExprKind::Quantifier { body, .. } => { + recurse(body)?; + } + PbesExprKind::Negation(inner) => { + recurse(inner)?; + } + PbesExprKind::Binary { lhs, rhs, .. } => { + recurse(lhs)?; + recurse(rhs)?; + } + PbesExprKind::DataValExpr(_) + | PbesExprKind::PropVarInst(_) + | PbesExprKind::True + | PbesExprKind::False => {} + }, +} + +define_traversal! { + node: PresExpr, + children: |recurse| { + PresExprKind::RightConstantMultiply { expr, .. } + | PresExprKind::LeftConstantMultiply { expr, .. } + | PresExprKind::Bound { expr, .. } => { + recurse(expr)?; + } + PresExprKind::Equal { body, .. } => { + recurse(body)?; + } + PresExprKind::Condition { lhs, then, else_, .. } => { + recurse(lhs)?; + recurse(then)?; + recurse(else_)?; + } + PresExprKind::Negation(inner) => { + recurse(inner)?; + } + PresExprKind::Binary { lhs, rhs, .. } => { + recurse(lhs)?; + recurse(rhs)?; + } + PresExprKind::DataValExpr(_) + | PresExprKind::PropVarInst(_) + | PresExprKind::True + | PresExprKind::False => {} + }, +} + +#[cfg(test)] +mod tests { + use std::convert::Infallible; + use std::ops::ControlFlow; + + use merc_utilities::Step; + + use crate::ActFrm; + use crate::ActFrmKind; + use crate::DataExpr; + use crate::DataExprKind; + use crate::PbesExprKind; + use crate::PresExprKind; + use crate::ProcessExprKind; + use crate::RegFrm; + use crate::RegFrmKind; + use crate::SortExpression; + use crate::SortExpressionKind; + use crate::StateFrm; + use crate::StateFrmKind; + use crate::Traverse; + use crate::UntypedDataSpecification; + use crate::UntypedPbes; + use crate::UntypedPres; + use crate::UntypedProcessSpecification; + use crate::UntypedStateFrmSpec; + use crate::traverse::Recursion; + + /// Parses a state formula, for example `mu X. [a]X`. + fn state_formula(input: &str) -> StateFrm { + UntypedStateFrmSpec::parse(input) + .expect("the state formula should parse") + .formula + } + + /// Parses a regular formula by putting it inside a modality, for example `a . b*`. + fn regular_formula(input: &str) -> RegFrm { + let formula = state_formula(&format!("[{input}]true")); + match formula.node { + StateFrmKind::Modality { formula, .. } => formula, + _ => panic!("expected a modality"), + } + } + + /// Parses an action formula, for example `a && b`. + fn action_formula(input: &str) -> ActFrm { + match regular_formula(input).node { + RegFrmKind::Action(act_frm) => act_frm, + _ => panic!("expected an action formula"), + } + } + + /// Parses a sort expression by declaring it as an alias, for example `A # B -> C`. + fn sort_expression(input: &str) -> SortExpression { + UntypedDataSpecification::parse(&format!("sort S = {input};")) + .expect("the sort expression should parse") + .sort_declarations + .remove(0) + .expr + .expect("the declaration is an alias") + } + + /// Collects the identifiers of a state formula in the order in which they are visited. + fn identifiers(formula: &StateFrm) -> Vec { + let mut result = Vec::new(); + + formula.visit::<(), _>(|formula| { + if let StateFrmKind::Id(name, _) = &formula.node { + result.push(name.clone()); + } + + ControlFlow::Continue(()) + }); + + result + } + + #[test] + fn test_visit_is_top_down_and_left_to_right() { + let formula = state_formula("mu X. [a]X && mu Y. Y && Z"); + + assert_eq!(identifiers(&formula), ["X", "Y", "Z"]); + } + + #[test] + fn test_visit_state_formula_breaks_from_nested_node() { + // `Z` only occurs below the top-level conjunction. + let formula = state_formula("true && (mu X. (X && Z))"); + + let found = formula.visit(|formula| match &formula.node { + StateFrmKind::Id(name, _) if name == "Z" => ControlFlow::Break(name.clone()), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found.as_deref(), Some("Z")); + } + + #[test] + fn test_visit_regular_formula_breaks_from_nested_node() { + let formula = regular_formula("a . (b* + c)"); + + let found = formula.visit(|formula| match &formula.node { + RegFrmKind::Iteration(_) => ControlFlow::Break("iteration"), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found, Some("iteration")); + } + + #[test] + fn test_visit_action_formula_breaks_from_nested_node() { + let formula = action_formula("a && (b || !c)"); + + let found = formula.visit(|formula| match &formula.node { + ActFrmKind::Negation(_) => ControlFlow::Break("negation"), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found, Some("negation")); + } + + #[test] + fn test_visit_sort_expression_breaks_from_nested_node() { + let sort = sort_expression("A # List(B) -> C"); + + let found = sort.visit(|sort| match &sort.node { + SortExpressionKind::Reference(name) if name == "B" => ControlFlow::Break(name.clone()), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found.as_deref(), Some("B")); + } + + #[test] + fn test_visit_data_expression_breaks_from_nested_node() { + let expr = DataExpr::parse("f(g(a), b)").expect("the data expression should parse"); + + let found = expr.visit(|expr| match &expr.node { + DataExprKind::Id(name) if name == "a" => ControlFlow::Break(name.clone()), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found.as_deref(), Some("a")); + } + + /// The children of a function update sit behind a `Box`, which the traversal has to reach + /// through explicitly. + #[test] + fn test_visit_data_expression_descends_into_function_update() { + let expr = DataExpr::parse("f[a -> b]").expect("the data expression should parse"); + + let mut names = Vec::new(); + expr.visit::<(), _>(|expr| { + if let DataExprKind::Id(name) = &expr.node { + names.push(name.clone()); + } + + ControlFlow::Continue(()) + }); + + assert_eq!(names, ["f", "a", "b"]); + } + + #[test] + fn test_visit_process_expression_breaks_from_nested_node() { + let spec = UntypedProcessSpecification::parse("init a . (sum n: Nat . b(n)) + delta;") + .expect("the process specification should parse"); + let process = spec.init.expect("the specification has an initial process"); + + let found = process.visit(|process| match &process.node { + ProcessExprKind::Delta => ControlFlow::Break("delta"), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found, Some("delta")); + } + + #[test] + fn test_visit_pbes_expression_breaks_from_nested_node() { + let pbes = UntypedPbes::parse("pbes mu X = forall n: Nat . (val(n < 3) => !X); init X;") + .expect("the PBES should parse"); + + let found = pbes.equations[0].formula.visit(|expr| match &expr.node { + PbesExprKind::Negation(_) => ControlFlow::Break("negation"), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found, Some("negation")); + } + + #[test] + fn test_visit_pres_expression_breaks_from_nested_node() { + let pres = + UntypedPres::parse("pres mu X = sup n: Nat . (val(n < 3) + X); init X;").expect("the PRES should parse"); + + // `X` is only reachable through the bound and the addition below it. + let found = pres.equations[0].formula.visit(|expr| match &expr.node { + PresExprKind::PropVarInst(instantiation) => ControlFlow::Break(instantiation.identifier.clone()), + _ => ControlFlow::Continue(()), + }); + + assert_eq!(found.as_deref(), Some("X")); + } + + #[test] + fn test_visit_prune_skips_the_children() { + let formula = state_formula("true && (mu X. (X && Z))"); + + // Everything below the fixpoint is skipped, so `Z` is never reached. + let found = formula.visit_with::<(), String, Infallible, _>((), |formula, context| { + Ok(match &formula.node { + StateFrmKind::Id(name, _) if name == "Z" => ControlFlow::Break(name.clone()), + StateFrmKind::FixedPoint { .. } => ControlFlow::Continue(Step::Prune), + _ => ControlFlow::Continue(Step::Into(context)), + }) + }); + + assert_eq!(found, Ok(None)); + } + + #[test] + fn test_visit_threads_the_context() { + let formula = state_formula("true && (mu X. (X && Z))"); + + // The context is the depth of the node, which is one more than that of its parent. + let mut depths = Vec::new(); + let found = formula.visit_with::(0, |formula, depth| { + if let StateFrmKind::Id(name, _) = &formula.node { + depths.push((name.clone(), depth)); + } + + Ok(ControlFlow::Continue(Step::Into(depth + 1))) + }); + + assert_eq!(found, Ok(None)); + assert_eq!(depths, [("X".to_string(), 3), ("Z".to_string(), 3)]); + } + + #[test] + fn test_visit_reports_the_error_of_the_callback() { + let formula = state_formula("mu X. X"); + + let result: Result, &str> = formula.try_visit(|_formula| Err("failed")); + + assert_eq!(result, Err("failed")); + } + + #[test] + fn test_apply_collects_variables() { + let formula = state_formula("mu X. [a]X && mu X. X && Y"); + + let mut variables = Vec::new(); + let result = formula.apply::(|formula| { + if let StateFrmKind::Id(name, _) = &formula.node { + variables.push(name.clone()); + } + + Ok(None) + }); + + assert!(result.is_ok()); + assert_eq!(variables, ["X", "X", "Y"]); + } + + #[test] + fn test_apply_without_replacement_is_the_identity() { + for input in [ + "mu X. [a . b*]X && nu Y. Y", + "forall n: Nat . val(n < 3) => [a(n)]false", + "true && (mu X. (X && Z))", + ] { + let formula = state_formula(input); + + let result = formula.clone().apply::(|_formula| Ok(None)); + + assert_eq!(result.as_ref(), Ok(&formula)); + } + } + + #[test] + fn test_apply_does_not_descend_into_the_replacement() { + let formula = state_formula("X && Y"); + + // The replacement of `X` contains an `X` again, which must not be replaced a second time. + let mut replacements = 0; + let result = formula.apply::(|formula| { + if let StateFrmKind::Id(name, _) = &formula.node + && name == "X" + { + replacements += 1; + return Ok(Some(state_formula("mu X0. X"))); + } + + Ok(None) + }); + + assert_eq!(replacements, 1); + assert_eq!( + format!("{}", result.expect("the callback cannot fail")), + "((mu X0 . X) && Y)" + ); + } + + #[test] + fn test_apply_with_breaks_and_keeps_what_was_rewritten() { + let mut formula = state_formula("X && Y"); + + let found = formula.apply_with::<(), &str, Infallible, _>((), |formula, context| { + Ok(match &formula.node { + StateFrmKind::Id(name, _) if name == "X" => { + ControlFlow::Continue(Step::Replace(StateFrmKind::True.into())) + } + StateFrmKind::Id(name, _) if name == "Y" => ControlFlow::Break("stopped"), + _ => ControlFlow::Continue(Step::Into(context)), + }) + }); + + assert_eq!(found, Ok(Some("stopped"))); + assert_eq!(format!("{formula}"), "(true && Y)"); + } + + /// The recursive step is the only place that knows the shape of a node, so a node type whose + /// children it forgets would silently lose them everywhere at once. + #[test] + fn test_visit_children_reaches_every_child() { + let formula = state_formula("[a]X && (nu Z0. Z0)"); + + // Pruning each child keeps only the direct children of the conjunction. + let mut children = Vec::new(); + let outcome: Recursion = formula.visit_children((), &mut |child, _context| { + children.push(format!("{child}")); + Ok(ControlFlow::Continue(Step::Prune)) + }); + + assert!(matches!(outcome, ControlFlow::Continue(()))); + assert_eq!(children, ["[a]X", "(nu Z0 . Z0)"]); + } +} diff --git a/crates/syntax/src/visitor.rs b/crates/syntax/src/visitor.rs deleted file mode 100644 index 1babe2cd5..000000000 --- a/crates/syntax/src/visitor.rs +++ /dev/null @@ -1,652 +0,0 @@ -use std::convert::Infallible; -use std::ops::ControlFlow; - -use merc_utilities::MercError; - -use crate::ActFrm; -use crate::ActFrmKind; -use crate::DataExpr; -use crate::DataExprKind; -use crate::RegFrm; -use crate::RegFrmKind; -use crate::SortExpression; -use crate::SortExpressionKind; -use crate::StateFrm; -use crate::StateFrmKind; - -/// Visits the state formula and calls the given function on each subformula. -/// -/// The visitor function takes a state formula and returns a `ControlFlow`. If -/// it returns `ControlFlow::Break(value)`, the traversal is stopped and the -/// value is returned. If it returns `ControlFlow::Continue(())`, the traversal -/// continues. -pub fn visit_statefrm(formula: &StateFrm, mut visitor: F) -> Result, MercError> -where - F: FnMut(&StateFrm) -> Result, MercError>, -{ - visit_statefrm_rec(formula, &mut visitor) -} - -/// Visits all sort expressions in the sort expression. -pub fn visit_sort_expr(sort_expr: &SortExpression, mut visitor: F) -> Option -where - F: FnMut(&SortExpression) -> ControlFlow, -{ - try_visit_sort_expr(sort_expr, |sort_expr| -> Result<_, Infallible> { - Ok(visitor(sort_expr)) - }) - .expect("Inner function does not fail") -} - -/// Visits all sort expressions in the sort expression, allowing the visitor to return an error. -pub fn try_visit_sort_expr(sort_expr: &SortExpression, mut visitor: F) -> Result, E> -where - F: FnMut(&SortExpression) -> Result, E>, -{ - visit_sort_expr_rec(sort_expr, &mut visitor) -} - -/// Visits all subexpressions of a data expression in pre-order. -pub fn visit_data_expr(expr: &DataExpr, mut visitor: F) -> Option -where - F: FnMut(&DataExpr) -> ControlFlow, -{ - try_visit_data_expr(expr, |expr| -> Result<_, Infallible> { Ok(visitor(expr)) }) - .expect("Inner function does not fail") -} - -/// Visits all subexpressions of a data expression in pre-order, allowing the -/// visitor to return an error. -pub fn try_visit_data_expr(expr: &DataExpr, mut visitor: F) -> Result, E> -where - F: FnMut(&DataExpr) -> Result, E>, -{ - visit_data_expr_rec(expr, &mut visitor) -} - -/// Visits all subexpressions of a data expression in pre-order, allowing the -/// visitor to mutate each node in place. Children are visited after the -/// visitor ran on their parent, so they are the children of the possibly -/// mutated node. -pub fn try_visit_data_expr_mut(expr: &mut DataExpr, mut visitor: F) -> Result, E> -where - F: FnMut(&mut DataExpr) -> Result, E>, -{ - visit_data_expr_mut_rec(expr, &mut visitor) -} - -/// Controls how [`try_visit_sort_expr_with`] proceeds below the current node. -pub enum SortDescend { - /// Visit the children, passing them the given context. - Descend(C), - /// Do not visit the children (the visitor handled them itself, or they are - /// irrelevant). - Prune, -} - -/// Visits all sort expressions top-down while threading a visitor-chosen -/// context from each node to its children, and allowing subtrees to be pruned. -/// -/// The context makes position-dependent checks expressible — e.g. "was a -/// function sort passed on the way here" — which the plain -/// [`try_visit_sort_expr`] cannot do. Note that all children of a node receive -/// the same context; if the children need different treatment, handle them in -/// the visitor and return [`SortDescend::Prune`]. -pub fn try_visit_sort_expr_with(sort_expr: &SortExpression, ctx: C, mut visitor: F) -> Result, E> -where - C: Copy, - F: FnMut(&SortExpression, C) -> Result>, E>, -{ - visit_sort_expr_with_rec(sort_expr, ctx, &mut visitor) -} - -/// See [`try_visit_sort_expr_with`]. -fn visit_sort_expr_with_rec(sort_expr: &SortExpression, ctx: C, visitor: &mut F) -> Result, E> -where - C: Copy, - F: FnMut(&SortExpression, C) -> Result>, E>, -{ - let ctx = match visitor(sort_expr, ctx)? { - ControlFlow::Break(result) => return Ok(Some(result)), - ControlFlow::Continue(SortDescend::Prune) => return Ok(None), - ControlFlow::Continue(SortDescend::Descend(ctx)) => ctx, - }; - - match &sort_expr.node { - SortExpressionKind::Product { lhs, rhs } => { - if let Some(result) = visit_sort_expr_with_rec(lhs, ctx, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_sort_expr_with_rec(rhs, ctx, visitor)? { - return Ok(Some(result)); - } - } - SortExpressionKind::Function { domain, range } => { - if let Some(result) = visit_sort_expr_with_rec(domain, ctx, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_sort_expr_with_rec(range, ctx, visitor)? { - return Ok(Some(result)); - } - } - SortExpressionKind::Struct { inner } => { - for constructor in inner { - for (_name, sort) in &constructor.args { - if let Some(result) = visit_sort_expr_with_rec(sort, ctx, visitor)? { - return Ok(Some(result)); - } - } - } - } - SortExpressionKind::Complex(_complex_sort, sort_expression) => { - if let Some(result) = visit_sort_expr_with_rec(sort_expression, ctx, visitor)? { - return Ok(Some(result)); - } - } - SortExpressionKind::FlattenedFunction { domain, range } => { - for domain_sort in domain { - if let Some(result) = visit_sort_expr_with_rec(domain_sort, ctx, visitor)? { - return Ok(Some(result)); - } - } - if let Some(result) = visit_sort_expr_with_rec(range, ctx, visitor)? { - return Ok(Some(result)); - } - } - SortExpressionKind::Reference(_) | SortExpressionKind::Simple(_) | SortExpressionKind::Resolved(_, _) => {} - } - - Ok(None) -} - -/// See [`visit_statefrm`]. -fn visit_statefrm_rec(formula: &StateFrm, function: &mut F) -> Result, MercError> -where - F: FnMut(&StateFrm) -> Result, MercError>, -{ - if let ControlFlow::Break(result) = function(formula)? { - // The visitor requested to break the traversal. - return Ok(Some(result)); - } - - match &formula.node { - StateFrmKind::Binary { lhs, rhs, .. } => { - if let Some(result) = visit_statefrm_rec(lhs, function)? { - return Ok(Some(result)); - } - if let Some(result) = visit_statefrm_rec(rhs, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::FixedPoint { body, .. } => { - if let Some(result) = visit_statefrm_rec(body, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::Bound { body, .. } => { - if let Some(result) = visit_statefrm_rec(body, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::Modality { expr, .. } => { - if let Some(result) = visit_statefrm_rec(expr, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::Quantifier { body, .. } => { - if let Some(result) = visit_statefrm_rec(body, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::DataValExprRightMult(expr, _data_val) => { - if let Some(result) = visit_statefrm_rec(expr, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::DataValExprLeftMult(_data_val, expr) => { - if let Some(result) = visit_statefrm_rec(expr, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::Unary { expr, .. } => { - if let Some(result) = visit_statefrm_rec(expr, function)? { - return Ok(Some(result)); - } - } - StateFrmKind::Id(_, _) - | StateFrmKind::True - | StateFrmKind::False - | StateFrmKind::Delay(_) - | StateFrmKind::Yaled(_) - | StateFrmKind::DataValExpr(_) => {} - } - - // The visitor did not break the traversal. - Ok(None) -} - -/// See [`visit_sort_expr`]. -fn visit_sort_expr_rec(sort_expr: &SortExpression, function: &mut F) -> Result, E> -where - F: FnMut(&SortExpression) -> Result, E>, -{ - if let ControlFlow::Break(result) = function(sort_expr)? { - // The visitor requested to break the traversal. - return Ok(Some(result)); - } - - match &sort_expr.node { - SortExpressionKind::Product { lhs, rhs } => { - if let Some(result) = visit_sort_expr_rec(lhs, function)? { - return Ok(Some(result)); - } - if let Some(result) = visit_sort_expr_rec(rhs, function)? { - return Ok(Some(result)); - } - } - SortExpressionKind::Function { domain, range } => { - if let Some(result) = visit_sort_expr_rec(domain, function)? { - return Ok(Some(result)); - } - if let Some(result) = visit_sort_expr_rec(range, function)? { - return Ok(Some(result)); - } - } - SortExpressionKind::Struct { inner } => { - for constructors in inner { - for (_name, sort) in &constructors.args { - if let Some(result) = visit_sort_expr_rec(sort, function)? { - return Ok(Some(result)); - } - } - } - } - SortExpressionKind::Complex(_complex_sort, sort_expression) => { - if let Some(result) = visit_sort_expr_rec(sort_expression, function)? { - return Ok(Some(result)); - } - } - SortExpressionKind::FlattenedFunction { domain, range } => { - for domain_sort in domain { - if let Some(result) = visit_sort_expr_rec(domain_sort, function)? { - return Ok(Some(result)); - } - } - if let Some(result) = visit_sort_expr_rec(range, function)? { - return Ok(Some(result)); - } - } - SortExpressionKind::Reference(_) | SortExpressionKind::Simple(_) | SortExpressionKind::Resolved(_, _) => {} - } - - // The visitor did not break the traversal. - Ok(None) -} - -/// See [`try_visit_data_expr`]. -fn visit_data_expr_rec(expr: &DataExpr, visitor: &mut F) -> Result, E> -where - F: FnMut(&DataExpr) -> Result, E>, -{ - if let ControlFlow::Break(result) = visitor(expr)? { - // The visitor requested to break the traversal. - return Ok(Some(result)); - } - - match &expr.node { - DataExprKind::Application { function, arguments } => { - if let Some(result) = visit_data_expr_rec(function, visitor)? { - return Ok(Some(result)); - } - for argument in arguments { - if let Some(result) = visit_data_expr_rec(argument, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::List(elements) | DataExprKind::Set(elements) => { - for element in elements { - if let Some(result) = visit_data_expr_rec(element, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::Bag(elements) => { - for element in elements { - if let Some(result) = visit_data_expr_rec(&element.expr, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_rec(&element.multiplicity, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::SetBagComp { variable: _, predicate } => { - if let Some(result) = visit_data_expr_rec(predicate, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Lambda { variables: _, body } - | DataExprKind::Quantifier { - op: _, - variables: _, - body, - } => { - if let Some(result) = visit_data_expr_rec(body, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Unary { op: _, expr } => { - if let Some(result) = visit_data_expr_rec(expr, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Binary { op: _, lhs, rhs } => { - if let Some(result) = visit_data_expr_rec(lhs, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_rec(rhs, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::FunctionUpdate { expr, update } => { - if let Some(result) = visit_data_expr_rec(expr, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_rec(&update.expr, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_rec(&update.update, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Whr { expr, assignments } => { - if let Some(result) = visit_data_expr_rec(expr, visitor)? { - return Ok(Some(result)); - } - for assignment in assignments { - if let Some(result) = visit_data_expr_rec(&assignment.expr, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::Id(_) - | DataExprKind::Number(_) - | DataExprKind::Bool(_) - | DataExprKind::EmptyList - | DataExprKind::EmptySet - | DataExprKind::EmptyBag => {} - } - - // The visitor did not break the traversal. - Ok(None) -} - -/// See [`try_visit_data_expr_mut`]. -fn visit_data_expr_mut_rec(expr: &mut DataExpr, visitor: &mut F) -> Result, E> -where - F: FnMut(&mut DataExpr) -> Result, E>, -{ - if let ControlFlow::Break(result) = visitor(expr)? { - // The visitor requested to break the traversal. - return Ok(Some(result)); - } - - match &mut expr.node { - DataExprKind::Application { function, arguments } => { - if let Some(result) = visit_data_expr_mut_rec(function, visitor)? { - return Ok(Some(result)); - } - for argument in arguments { - if let Some(result) = visit_data_expr_mut_rec(argument, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::List(elements) | DataExprKind::Set(elements) => { - for element in elements { - if let Some(result) = visit_data_expr_mut_rec(element, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::Bag(elements) => { - for element in elements { - if let Some(result) = visit_data_expr_mut_rec(&mut element.expr, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_mut_rec(&mut element.multiplicity, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::SetBagComp { variable: _, predicate } => { - if let Some(result) = visit_data_expr_mut_rec(predicate, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Lambda { variables: _, body } - | DataExprKind::Quantifier { - op: _, - variables: _, - body, - } => { - if let Some(result) = visit_data_expr_mut_rec(body, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Unary { op: _, expr } => { - if let Some(result) = visit_data_expr_mut_rec(expr, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Binary { op: _, lhs, rhs } => { - if let Some(result) = visit_data_expr_mut_rec(lhs, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_mut_rec(rhs, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::FunctionUpdate { expr, update } => { - if let Some(result) = visit_data_expr_mut_rec(expr, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_mut_rec(&mut update.expr, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_data_expr_mut_rec(&mut update.update, visitor)? { - return Ok(Some(result)); - } - } - DataExprKind::Whr { expr, assignments } => { - if let Some(result) = visit_data_expr_mut_rec(expr, visitor)? { - return Ok(Some(result)); - } - for assignment in assignments { - if let Some(result) = visit_data_expr_mut_rec(&mut assignment.expr, visitor)? { - return Ok(Some(result)); - } - } - } - DataExprKind::Id(_) - | DataExprKind::Number(_) - | DataExprKind::Bool(_) - | DataExprKind::EmptyList - | DataExprKind::EmptySet - | DataExprKind::EmptyBag => {} - } - - // The visitor did not break the traversal. - Ok(None) -} - -/// Maps the given `function` recursively to the regular formula. -pub fn visit_regular_formula(formula: &RegFrm, mut function: F) -> Result, MercError> -where - F: FnMut(&RegFrm) -> Result, MercError>, -{ - visit_regular_formula_rec(formula, &mut function) -} - -/// See [visit_regular_formula]. -fn visit_regular_formula_rec(formula: &RegFrm, visit: &mut F) -> Result, MercError> -where - F: FnMut(&RegFrm) -> Result, MercError>, -{ - if let ControlFlow::Break(result) = visit(formula)? { - // A substitution was made, return the new formula. - return Ok(Some(result)); - } - - match &formula.node { - RegFrmKind::Iteration(reg_frm) => { - if let Some(result) = visit_regular_formula_rec(reg_frm, visit)? { - return Ok(Some(result)); - } - } - RegFrmKind::Plus(reg_frm) => { - if let Some(result) = visit_regular_formula_rec(reg_frm, visit)? { - return Ok(Some(result)); - } - } - RegFrmKind::Sequence { lhs, rhs } => { - if let Some(result) = visit_regular_formula_rec(lhs, visit)? { - return Ok(Some(result)); - } - if let Some(result) = visit_regular_formula_rec(rhs, visit)? { - return Ok(Some(result)); - } - } - RegFrmKind::Choice { lhs, rhs } => { - if let Some(result) = visit_regular_formula_rec(lhs, visit)? { - return Ok(Some(result)); - } - if let Some(result) = visit_regular_formula_rec(rhs, visit)? { - return Ok(Some(result)); - } - } - _ => {} - } - - Ok(None) -} - -/// Visitor for action formulas. -/// -pub fn visit_action_formula(formula: &ActFrm, mut visitor: F) -> Result, MercError> -where - F: FnMut(&ActFrm) -> Result, MercError>, -{ - visit_action_formula_rec(formula, &mut visitor) -} - -fn visit_action_formula_rec(formula: &ActFrm, visitor: &mut F) -> Result, MercError> -where - F: FnMut(&ActFrm) -> Result, MercError>, -{ - if let ControlFlow::Break(result) = visitor(formula)? { - // The visitor requested to break the traversal. - return Ok(Some(result)); - } - - match &formula.node { - ActFrmKind::Negation(act_frm) => { - if let Some(result) = visit_action_formula_rec(act_frm, visitor)? { - return Ok(Some(result)); - } - } - ActFrmKind::Quantifier { - quantifier: _, - variables: _, - body, - } => { - if let Some(result) = visit_action_formula_rec(body, visitor)? { - return Ok(Some(result)); - } - } - ActFrmKind::Binary { op: _, lhs, rhs } => { - if let Some(result) = visit_action_formula_rec(lhs, visitor)? { - return Ok(Some(result)); - } - if let Some(result) = visit_action_formula_rec(rhs, visitor)? { - return Ok(Some(result)); - } - } - ActFrmKind::True | ActFrmKind::False | ActFrmKind::MultAct(_) | ActFrmKind::DataExprVal(_) => {} - } - - // The visitor did not break the traversal. - Ok(None) -} - -#[cfg(test)] -mod tests { - use std::convert::Infallible; - use std::ops::ControlFlow; - - use crate::DataExpr; - use crate::DataExprKind; - use crate::Sort; - use crate::SortExpressionKind; - - use super::try_visit_data_expr_mut; - use super::visit_data_expr; - use super::visit_sort_expr; - - /// Regression test: the FlattenedFunction arm used to discard `Break` - /// results from both the domain sorts and the range. - #[test] - fn test_visit_sort_expr_breaks_inside_flattened_function() { - let sort = SortExpressionKind::FlattenedFunction { - domain: vec![SortExpressionKind::Simple(Sort::Nat).into()], - range: Box::new(SortExpressionKind::Simple(Sort::Bool).into()), - } - .into(); - - let found = visit_sort_expr(&sort, |expr| match &expr.node { - SortExpressionKind::Simple(Sort::Nat) => ControlFlow::Break("domain"), - _ => ControlFlow::Continue(()), - }); - assert_eq!(found, Some("domain")); - - let found = visit_sort_expr(&sort, |expr| match &expr.node { - SortExpressionKind::Simple(Sort::Bool) => ControlFlow::Break("range"), - _ => ControlFlow::Continue(()), - }); - assert_eq!(found, Some("range")); - } - - /// The easy-to-miss children (bag multiplicities and whr assignments) are - /// visited as well. - #[test] - fn test_visit_data_expr_reaches_nested_children() { - let expr = DataExpr::parse("f(v) whr v = { e: m } end").unwrap(); - - for name in ["v", "e", "m"] { - let found = visit_data_expr(&expr, |expr| match &expr.node { - DataExprKind::Id(id) if id == name => ControlFlow::Break(()), - _ => ControlFlow::Continue(()), - }); - assert_eq!(found, Some(()), "identifier {name} was not visited"); - } - } - - #[test] - fn test_try_visit_data_expr_mut_rewrites_in_place() { - let mut expr = DataExpr::parse("x + f(x)").unwrap(); - - let result: Option = try_visit_data_expr_mut(&mut expr, |expr| { - if let DataExprKind::Id(name) = &mut expr.node - && name == "x" - { - *name = "y".to_string(); - } - Ok::<_, Infallible>(ControlFlow::Continue(())) - }) - .unwrap(); - - assert!(result.is_none()); - assert_eq!(expr, DataExpr::parse("y + f(y)").unwrap()); - } -} diff --git a/crates/syntax/tests/roundtrip_test.rs b/crates/syntax/tests/roundtrip_test.rs index 8f2944dec..e279ba305 100644 --- a/crates/syntax/tests/roundtrip_test.rs +++ b/crates/syntax/tests/roundtrip_test.rs @@ -11,6 +11,7 @@ use merc_syntax::ProcExprBinaryOp; use merc_syntax::ProcessExprKind; use merc_syntax::Span; use merc_syntax::StateFrmKind; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; use merc_syntax::UntypedPbes; use merc_syntax::UntypedPres; @@ -20,11 +21,8 @@ use merc_syntax::line_column; use merc_syntax::make_process_specification; use merc_syntax::random_lps; use merc_syntax::random_pbes; -use merc_syntax::visit_statefrm; use merc_utilities::random_test; -// --- Regression tests for the review fixes ------------------------------------------------------- - /// PBES quantifiers used to panic because `forall`/`exists` were registered as /// prefix operators but only handled in the postfix closure. #[test] @@ -113,15 +111,14 @@ fn visitor_breaks_from_nested_node() { // The `Y` identifier only appears below the top-level conjunction. let spec = UntypedStateFrmSpec::parse("true && (mu X. (X && Y))").unwrap(); - let found = visit_statefrm(&spec.formula, |frm| { + let found = spec.formula.visit(|frm| { if let StateFrmKind::Id(name, _) = &frm.node && name == "Y" { - return Ok(ControlFlow::Break(name.clone())); + return ControlFlow::Break(name.clone()); } - Ok(ControlFlow::Continue(())) - }) - .unwrap(); + ControlFlow::Continue(()) + }); assert_eq!(found.as_deref(), Some("Y"), "Break value from a nested node was lost"); } @@ -200,8 +197,6 @@ fn act_decl_with_args_prints_colon_hash() { UntypedProcessSpecification::parse(&printed).expect("printed form must reparse"); } -// --- Randomized print/parse round-trip properties ------------------------------------------------ - /// Property: for every generated AST, the printed form parses, and printing the /// reparsed AST yields exactly the same string (a fixpoint of `parse ∘ display`). /// This catches Display/grammar mismatches without depending on `PartialEq` diff --git a/crates/typecheck/src/data_specification.rs b/crates/typecheck/src/data_specification.rs index 67454f063..da224a3cc 100644 --- a/crates/typecheck/src/data_specification.rs +++ b/crates/typecheck/src/data_specification.rs @@ -17,8 +17,8 @@ use merc_syntax::EquationId; use merc_syntax::MapId; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::apply_sort_expression; use crate::AliasError; use crate::EquationTyping; @@ -450,23 +450,24 @@ pub(crate) fn argument_sorts(sort: &SortExpression) -> &[SortExpression] { /// Rewrites every `Function` node of `sort` into a `FlattenedFunction` whose /// domain is the flattened `Product` spine (`(A#B)->C` becomes `A#B->C`). fn flatten_function_sorts(sort: &SortExpression) -> SortExpression { - apply_sort_expression(sort.clone(), |expr| -> Result<_, Infallible> { - if let SortExpressionKind::Function { domain, range } = &expr.node { - let mut flattened_domain = Vec::new(); - flatten_function_domain_rec(domain, &mut flattened_domain); - - return Ok(Some( - SortExpressionKind::FlattenedFunction { - domain: flattened_domain, - range: range.clone(), - } - .into(), - )); - } + sort.clone() + .apply(|expr| -> Result<_, Infallible> { + if let SortExpressionKind::Function { domain, range } = &expr.node { + let mut flattened_domain = Vec::new(); + flatten_function_domain_rec(domain, &mut flattened_domain); + + return Ok(Some( + SortExpressionKind::FlattenedFunction { + domain: flattened_domain, + range: range.clone(), + } + .into(), + )); + } - Ok(None) - }) - .expect("flatten_function_sorts should not fail") + Ok(None) + }) + .expect("flatten_function_sorts should not fail") } /// Flattens a function sort of the form ((A_0 # A_1) # ... # A_n) -> B into a diff --git a/crates/typecheck/src/ir/desugar.rs b/crates/typecheck/src/ir/desugar.rs index f5ae81f25..68b803721 100644 --- a/crates/typecheck/src/ir/desugar.rs +++ b/crates/typecheck/src/ir/desugar.rs @@ -15,9 +15,8 @@ use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; use merc_syntax::Span; use merc_syntax::Spanned; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::apply_sort_expression; -use merc_syntax::map_data_expr; /// Hoists every anonymous structured sort (a `struct` occurring inside another /// sort expression rather than as the body of a sort declaration) into a fresh @@ -96,40 +95,22 @@ pub(crate) fn hoist_anonymous_structs(spec: &mut UntypedDataSpecification) { /// binder over an anonymous `struct` would be left with an unresolvable sort /// and its equation rejected rather than type checked. fn hoist_binder_sorts_in_place(hoister: &mut Hoister, expr: &mut DataExpr) { - let owned = std::mem::replace(expr, DataExprKind::EmptyList.into()); - *expr = hoist_binder_sorts(hoister, owned); -} - -fn hoist_binder_sorts(hoister: &mut Hoister, expr: DataExpr) -> DataExpr { - map_data_expr(expr, |expr| { - let DataExpr { node, span } = expr; - match node { - DataExprKind::SetBagComp { - mut variable, - predicate, - } => { - variable.sort = hoister.hoist_non_decl(variable.sort); - DataExprKind::SetBagComp { variable, predicate }.spanned(span) - } - DataExprKind::Lambda { mut variables, body } => { - for variable in &mut variables { - variable.sort = hoister.hoist_non_decl(variable.sort.clone()); - } - DataExprKind::Lambda { variables, body }.spanned(span) - } - DataExprKind::Quantifier { - op, - mut variables, - body, - } => { - for variable in &mut variables { - variable.sort = hoister.hoist_non_decl(variable.sort.clone()); - } - DataExprKind::Quantifier { op, variables, body }.spanned(span) + expr.transform(|expr| match &mut expr.node { + DataExprKind::SetBagComp { variable, predicate: _ } => { + variable.sort = hoister.hoist_non_decl(variable.sort.clone()); + } + DataExprKind::Lambda { variables, body: _ } + | DataExprKind::Quantifier { + op: _, + variables, + body: _, + } => { + for variable in variables { + variable.sort = hoister.hoist_non_decl(variable.sort.clone()); } - node => node.spanned(span), } - }) + _ => {} + }); } struct Hoister { @@ -148,7 +129,7 @@ impl Hoister { /// named sort declaration's constructor arguments (the only positions that /// should expose global constructors). fn hoist(&mut self, sort: SortExpression) -> SortExpression { - apply_sort_expression(sort, |expr| -> Result, Infallible> { + sort.apply(|expr| -> Result, Infallible> { if let SortExpressionKind::Struct { inner } = &expr.node { // Hoist the constructor arguments first, so identical structs // have identical bodies regardless of nesting. @@ -181,7 +162,7 @@ impl Hoister { /// declaration-position occurrence, the existing name (with its full body) /// is reused, preserving the constructor visibility of that declaration. fn hoist_non_decl(&mut self, sort: SortExpression) -> SortExpression { - apply_sort_expression(sort, |expr| -> Result, Infallible> { + sort.apply(|expr| -> Result, Infallible> { if let SortExpressionKind::Struct { inner } = &expr.node { let mut inner = inner.clone(); for constructor in &mut inner { diff --git a/crates/typecheck/src/ir/lower.rs b/crates/typecheck/src/ir/lower.rs index d7c363446..17f0c5b07 100644 --- a/crates/typecheck/src/ir/lower.rs +++ b/crates/typecheck/src/ir/lower.rs @@ -6,9 +6,8 @@ use merc_syntax::DataExpr; use merc_syntax::DataExprBinaryOp; use merc_syntax::DataExprKind; use merc_syntax::Span; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::map_data_expr; -use merc_syntax::visit_data_expr; /// The Appendix-B name of the function update operation, see /// `crates/syntax/spec/function_update.mcrl2`. @@ -53,10 +52,11 @@ pub(crate) fn lower_data_expressions(spec: &mut UntypedDataSpecification) { /// kept as dedicated nodes: sort inference treats them specially, constraining /// their sort structurally instead of through a declared symbol. The result /// satisfies [is_lowered]; lowering is idempotent. -pub(crate) fn lower_data_expr(expr: DataExpr) -> DataExpr { - map_data_expr(expr, |expr| { - let DataExpr { node, span } = expr; - match node { +pub(crate) fn lower_data_expr(mut expr: DataExpr) -> DataExpr { + expr.transform(|expr| { + // The node is taken out so that its parts can be moved into the replacement. + let DataExpr { node, span } = std::mem::replace(expr, DataExprKind::EmptyList.into()); + *expr = match node { DataExprKind::Binary { op, lhs, rhs } => apply(op.to_string(), vec![*lhs, *rhs], span), DataExprKind::Unary { op, expr } => apply(op.to_string(), vec![*expr], span), DataExprKind::List(elements) => elements @@ -71,15 +71,17 @@ pub(crate) fn lower_data_expr(expr: DataExpr) -> DataExpr { span, ), node => node.spanned(span), - } - }) + }; + }); + + expr } /// Returns true when the expression contains none of the nodes that /// [lower_data_expr] rewrites; the postcondition of lowering and the /// precondition of Phase-3 sort inference. pub(crate) fn is_lowered(expr: &DataExpr) -> bool { - visit_data_expr(expr, |expr| match &expr.node { + expr.visit(|expr| match &expr.node { DataExprKind::Binary { .. } | DataExprKind::Unary { .. } | DataExprKind::List(_) diff --git a/crates/typecheck/src/resolution/alias.rs b/crates/typecheck/src/resolution/alias.rs index e633cd970..ab02a5485 100644 --- a/crates/typecheck/src/resolution/alias.rs +++ b/crates/typecheck/src/resolution/alias.rs @@ -3,12 +3,12 @@ use std::ops::ControlFlow; use merc_syntax::ComplexSort; use merc_syntax::DefId; -use merc_syntax::SortDescend; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; use merc_syntax::Span; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::try_visit_sort_expr_with; +use merc_utilities::Step; /// An error found in the alias declarations by [check_aliases]. #[derive(Debug, Eq, PartialEq, thiserror::Error)] @@ -70,7 +70,7 @@ fn check_circularity( visited: &mut Vec, alias_map: &HashMap, ) -> Result<(), AliasError> { - try_visit_sort_expr_with::(rhs, (), |expr, ()| match &expr.node { + rhs.visit_with::<(), (), AliasError, _>((), |expr, ()| match &expr.node { SortExpressionKind::Resolved(_, id) => { if *id == lhs { let mut cycle = vec![lhs]; @@ -84,13 +84,13 @@ fn check_circularity( check_circularity(lhs, alias, visited, alias_map)?; visited.pop(); } - Ok(ControlFlow::Continue(SortDescend::Descend(()))) + Ok(ControlFlow::Continue(Step::Into(()))) } // Recursion through a structured sort is well-defined, so the search // deliberately stops here. - SortExpressionKind::Struct { .. } => Ok(ControlFlow::Continue(SortDescend::Prune)), + SortExpressionKind::Struct { .. } => Ok(ControlFlow::Continue(Step::Prune)), SortExpressionKind::Reference(_) => unreachable!("Names must have been resolved"), - _ => Ok(ControlFlow::Continue(SortDescend::Descend(()))), + _ => Ok(ControlFlow::Continue(Step::Into(()))), }) .map(|_| ()) } @@ -107,7 +107,7 @@ fn check_function_sort_loop( is_function_like_sort: bool, alias_map: &HashMap, ) -> Result<(), AliasError> { - try_visit_sort_expr_with::(rhs, is_function_like_sort, |expr, observed| match &expr.node { + rhs.visit_with::(is_function_like_sort, |expr, observed| match &expr.node { SortExpressionKind::Resolved(_, id) => { if *id == lhs && observed { return Err(AliasError::ThroughFunctionSort { sort: lhs }); @@ -119,20 +119,20 @@ fn check_function_sort_loop( check_function_sort_loop(lhs, alias, visited, observed, alias_map)?; visited.pop(); } - Ok(ControlFlow::Continue(SortDescend::Descend(observed))) + Ok(ControlFlow::Continue(Step::Into(observed))) } // The container kind *replaces* the flag, as in mCRL2: passing through // a List (or FSet/FBag) resets an earlier function-sort observation, so // `struct f(Bool -> List(S))` is accepted. - SortExpressionKind::Complex(op, _) => Ok(ControlFlow::Continue(SortDescend::Descend(matches!( + SortExpressionKind::Complex(op, _) => Ok(ControlFlow::Continue(Step::Into(matches!( op, ComplexSort::Set | ComplexSort::Bag )))), SortExpressionKind::Function { .. } | SortExpressionKind::FlattenedFunction { .. } => { - Ok(ControlFlow::Continue(SortDescend::Descend(true))) + Ok(ControlFlow::Continue(Step::Into(true))) } SortExpressionKind::Reference(_) => unreachable!("Names must have been resolved"), - _ => Ok(ControlFlow::Continue(SortDescend::Descend(observed))), + _ => Ok(ControlFlow::Continue(Step::Into(observed))), }) .map(|_| ()) } diff --git a/crates/typecheck/src/resolution/name_resolution.rs b/crates/typecheck/src/resolution/name_resolution.rs index 8f3256fa9..4af372ce6 100644 --- a/crates/typecheck/src/resolution/name_resolution.rs +++ b/crates/typecheck/src/resolution/name_resolution.rs @@ -1,6 +1,4 @@ use std::collections::HashSet; -use std::convert::Infallible; -use std::ops::ControlFlow; use log::debug; @@ -15,9 +13,8 @@ use merc_syntax::EquationId; use merc_syntax::MapId; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::apply_sort_expression; -use merc_syntax::try_visit_data_expr_mut; use crate::WellTypedError; @@ -128,7 +125,7 @@ fn apply_sorts_in_data_expr(expr: &mut DataExpr, f: &mut F) -> Result<(), where F: FnMut(&SortExpression) -> Result, { - let _: Option = try_visit_data_expr_mut(expr, |expr| { + expr.try_transform(&mut |expr| { match &mut expr.node { DataExprKind::Lambda { variables, body: _ } | DataExprKind::Quantifier { @@ -145,16 +142,14 @@ where } _ => {} } - Ok(ControlFlow::Continue(())) - })?; - - Ok(()) + Ok(()) + }) } /// Rewrites every `Reference` node of `sort` to `Resolved(name, DefId)` using /// the sort-name index built by [resolve_names], or fails on an undeclared name. fn resolve_sort_id(sort: &SortExpression, resolved: &IndexedSet) -> Result { - apply_sort_expression(sort.clone(), |expr| { + sort.clone().apply(|expr| { if let SortExpressionKind::Reference(name) = &expr.node { if let Some(id) = resolved.index(name) { return Ok(Some(SortExpressionKind::Resolved(name.clone(), DefId::new(*id)).into())); diff --git a/crates/typecheck/src/resolution/normalize.rs b/crates/typecheck/src/resolution/normalize.rs index 675889daa..aacd9427d 100644 --- a/crates/typecheck/src/resolution/normalize.rs +++ b/crates/typecheck/src/resolution/normalize.rs @@ -7,8 +7,8 @@ use merc_syntax::DefId; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; use merc_syntax::Spanned; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::apply_sort_expression; use crate::apply_sorts_in_spec; @@ -54,32 +54,33 @@ fn normalize_sort( alias_map: &HashMap, visited: &mut Vec, ) -> SortExpression { - apply_sort_expression(sort.clone(), |expr| -> Result<_, Infallible> { - let SortExpressionKind::Resolved(_, id) = &expr.node else { - return Ok(None); - }; - - // A structured-sort alias, an abstract sort, or an alias reached again - // while it is being expanded, is a named representative: keep the name - // and do not recurse, so recursion through a `struct` terminates. - if visited.contains(id) { - return Ok(None); - } - match alias_map.get(id) { - Some(Spanned { - node: SortExpressionKind::Struct { .. }, - .. - }) - | None => Ok(None), - Some(alias) => { - visited.push(*id); - let result = normalize_sort(alias, alias_map, visited); - visited.pop(); - Ok(Some(result)) + sort.clone() + .apply(|expr| -> Result<_, Infallible> { + let SortExpressionKind::Resolved(_, id) = &expr.node else { + return Ok(None); + }; + + // A structured-sort alias, an abstract sort, or an alias reached again + // while it is being expanded, is a named representative: keep the name + // and do not recurse, so recursion through a `struct` terminates. + if visited.contains(id) { + return Ok(None); } - } - }) - .expect("normalization never fails") + match alias_map.get(id) { + Some(Spanned { + node: SortExpressionKind::Struct { .. }, + .. + }) + | None => Ok(None), + Some(alias) => { + visited.push(*id); + let result = normalize_sort(alias, alias_map, visited); + visited.pop(); + Ok(Some(result)) + } + } + }) + .expect("normalization never fails") } #[cfg(test)] diff --git a/crates/typecheck/src/signature/is_well_typed.rs b/crates/typecheck/src/signature/is_well_typed.rs index 848f920f8..f0923270b 100644 --- a/crates/typecheck/src/signature/is_well_typed.rs +++ b/crates/typecheck/src/signature/is_well_typed.rs @@ -3,13 +3,13 @@ use std::ops::ControlFlow; use thiserror::Error; -use merc_syntax::SortDescend; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; use merc_syntax::Span; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::try_visit_sort_expr_with; use merc_utilities::MercError; +use merc_utilities::Step; use crate::InferenceError; use crate::nonempty_sorts; @@ -172,7 +172,7 @@ impl WellTypedError { /// visitor context cannot express (all children receive the same context), so /// that case is handled manually and pruned. pub(crate) fn check_products_within_domains(sort: &SortExpression) -> Result<(), WellTypedError> { - try_visit_sort_expr_with::(sort, (), |expr, ()| match &expr.node { + sort.visit_with::<(), (), WellTypedError, _>((), |expr, ()| match &expr.node { SortExpressionKind::Product { .. } => Err(WellTypedError::ProductSortOutsideFunctionDomain { sort: expr.to_string(), span: expr.span.clone(), @@ -180,9 +180,9 @@ pub(crate) fn check_products_within_domains(sort: &SortExpression) -> Result<(), SortExpressionKind::Function { domain, range } => { check_product_spine(domain)?; check_products_within_domains(range)?; - Ok(ControlFlow::Continue(SortDescend::Prune)) + Ok(ControlFlow::Continue(Step::Prune)) } - _ => Ok(ControlFlow::Continue(SortDescend::Descend(()))), + _ => Ok(ControlFlow::Continue(Step::Into(()))), }) .map(|_| ()) } diff --git a/crates/typecheck/src/signature/standard_sorts.rs b/crates/typecheck/src/signature/standard_sorts.rs index cf2a74812..93413772e 100644 --- a/crates/typecheck/src/signature/standard_sorts.rs +++ b/crates/typecheck/src/signature/standard_sorts.rs @@ -8,8 +8,8 @@ use merc_syntax::ComplexSort; use merc_syntax::ConstructorDecl; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::apply_sort_expression; use merc_utilities::MercError; use crate::BASIC_SORT_NAMES; @@ -336,16 +336,17 @@ fn replace_sort(spec: &UntypedDataSpecification, identifier: &str, sort: &SortEx /// Replaces sort references of `identifier` in `sort` by the given `result_sort`. fn replace_sort_expression(sort: &SortExpression, identifier: &str, result_sort: &SortExpression) -> SortExpression { - apply_sort_expression(sort.clone(), |expr| -> Result, Infallible> { - if let SortExpressionKind::Reference(id) = &expr.node - && id == identifier - { - return Ok(Some(result_sort.clone())); - } + sort.clone() + .apply(|expr| -> Result, Infallible> { + if let SortExpressionKind::Reference(id) = &expr.node + && id == identifier + { + return Ok(Some(result_sort.clone())); + } - Ok(None) - }) - .unwrap() + Ok(None) + }) + .unwrap() } /// Generates the defining equations of a structured sort, following Appendix `B.10`. diff --git a/crates/typecheck/src/signature/system_check.rs b/crates/typecheck/src/signature/system_check.rs index 39e2e6680..022de56a7 100644 --- a/crates/typecheck/src/signature/system_check.rs +++ b/crates/typecheck/src/signature/system_check.rs @@ -6,8 +6,8 @@ use merc_syntax::DataExprKind; use merc_syntax::IdDecl; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::visit_sort_expr; use crate::WellTypedError; use crate::builtin_scheme_names; @@ -169,7 +169,7 @@ impl Checker<'_> { /// Checks that a sort of the system specification references only declared /// sorts, and places products only in function domains. fn check_sort(&self, sort: &SortExpression) -> Result<(), WellTypedError> { - let error = visit_sort_expr(sort, |expr| match &expr.node { + let error = sort.visit(|expr| match &expr.node { SortExpressionKind::Reference(name) if !self.sort_names.contains(name.as_str()) => ControlFlow::Break( format!("the system-defined specification references the undeclared sort '{name}'"), ), diff --git a/crates/typecheck/src/signature/system_defined.rs b/crates/typecheck/src/signature/system_defined.rs index 07026c09f..3d64b1f08 100644 --- a/crates/typecheck/src/signature/system_defined.rs +++ b/crates/typecheck/src/signature/system_defined.rs @@ -8,9 +8,8 @@ use merc_syntax::DataExpr; use merc_syntax::DataExprKind; use merc_syntax::SortExpression; use merc_syntax::SortExpressionKind; +use merc_syntax::Traverse; use merc_syntax::UntypedDataSpecification; -use merc_syntax::visit_data_expr; -use merc_syntax::visit_sort_expr; use crate::NumberEncoding; use crate::POLYMORPHIC_SIGNATURE; @@ -345,7 +344,7 @@ fn collect_system_sorts_in_spec( /// [is_supported_binder_sort]) are skipped: inference rejects the constructs /// that bind them, so their operators are never looked up. fn collect_system_sorts_in_expr(expr: &DataExpr, out: &mut Vec, include_functions: bool) { - visit_data_expr::<(), _>(expr, |expr| { + expr.visit::<(), _>(|expr| { match &expr.node { DataExprKind::SetBagComp { variable, predicate: _ } => { if is_supported_binder_sort(&variable.sort) { @@ -383,7 +382,7 @@ fn collect_system_sorts_in_expr(expr: &DataExpr, out: &mut Vec, /// multi-argument domain is passed through as `FlattenedFunction`, which /// `standard_sort`'s multi-argument branch consumes directly. fn collect_system_sorts(sort: &SortExpression, out: &mut Vec, include_functions: bool) { - visit_sort_expr::<(), _>(sort, |expr| { + sort.visit::<(), _>(|expr| { match &expr.node { SortExpressionKind::Complex(_, _) => out.push(expr.clone()), // A user specification carries flattened function sorts; the diff --git a/crates/unsafety/src/lib.rs b/crates/unsafety/src/lib.rs index bc4df014e..b7500e90a 100644 --- a/crates/unsafety/src/lib.rs +++ b/crates/unsafety/src/lib.rs @@ -21,6 +21,8 @@ pub use block_allocator::BlockAllocator; pub use block_allocator::BlockAllocatorSafe; pub use concurrent_append_vec::ConcurrentAppendVec; pub use concurrent_indexed_set::ConcurrentIndexedSet; +pub use counting_allocator::AllocCounter; +pub use counting_allocator::AllocMetrics; pub use erasable::Erasable; pub use erasable::ErasedPtr; pub use global_allocator::print_allocator_metrics; diff --git a/crates/utilities/src/lib.rs b/crates/utilities/src/lib.rs index dda0fbfea..476f214ed 100644 --- a/crates/utilities/src/lib.rs +++ b/crates/utilities/src/lib.rs @@ -18,6 +18,7 @@ mod sharded_counter; mod tagged_index; mod test_logger; mod timing; +mod traversal; pub(crate) use fixed_cache_policy::*; @@ -36,6 +37,8 @@ pub use tagged_index::TagIndex; pub use test_logger::test_logger; pub use test_logger::test_threads; pub use timing::Timing; +pub use traversal::Step; +pub use traversal::Visit; #[cfg(kani)] pub use kani_rng::*; diff --git a/crates/utilities/src/traversal.rs b/crates/utilities/src/traversal.rs new file mode 100644 index 000000000..2071b1cab --- /dev/null +++ b/crates/utilities/src/traversal.rs @@ -0,0 +1,33 @@ +use std::ops::ControlFlow; + +/// What a traversal does below the node that its callback has just seen. +/// +/// `N` is the type of a replacement node. Instantiating `N = Infallible` makes [Step::Replace] +/// impossible to construct, which is how the read-only traversals rule substitution out without +/// needing a second enum. +/// +/// Terms and syntax trees are often maximally shared or repeated, so a callback whose work per +/// node is not trivial can remember the nodes it has already seen and return [Step::Prune] for +/// the repeats, which keeps the traversal linear in the size of the graph rather than of the +/// tree it unfolds to. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] +pub enum Step { + /// Replace the node with `N`. The replacement is deliberately *not* descended into, since a + /// callback that rewrites a node into something containing that same node would otherwise + /// never terminate. Recurse explicitly on the replacement when that is wanted. + Replace(N), + /// Keep the node and descend into its children, carrying this context. + Into(C), + /// Keep the node but skip its children. + Prune, +} + +/// The result of visiting a single node: fail with `E`, stop the traversal with `T`, or continue +/// with a [Step]. +/// +/// The context `C` is threaded from a node to its children, which lets a traversal track where it +/// is without maintaining a stack of its own. It is required to be `Copy`: a `Clone` context is +/// cloned at every node, which is a performance trap dressed up as flexibility. State that grows +/// along a path, such as the variables bound above the current node, belongs in the callback +/// itself (push on entry, truncate on exit) or in a `Copy` slice. +pub type Visit = Result>, E>; diff --git a/crates/vpg/src/modal_equation_system.rs b/crates/vpg/src/modal_equation_system.rs index 552ce2b55..122af3948 100644 --- a/crates/vpg/src/modal_equation_system.rs +++ b/crates/vpg/src/modal_equation_system.rs @@ -1,4 +1,5 @@ use std::collections::HashSet; +use std::convert::Infallible; use std::fmt; use std::ops::ControlFlow; @@ -8,8 +9,7 @@ use merc_syntax::FixedPointOperator; use merc_syntax::StateFrm; use merc_syntax::StateFrmKind; use merc_syntax::StateVarDecl; -use merc_syntax::apply_statefrm; -use merc_syntax::visit_statefrm; +use merc_syntax::Traverse; /// A fixpoint equation system representing a ranked set of fixpoint equations. /// @@ -127,40 +127,49 @@ impl ModalEquationSystem { } /// Recursive helper function to compute the alternation depth of equation `i`. + /// + /// # Details + /// + /// The depth of a formula is the largest depth of the variables occurring in it, so the + /// traversal only has to look at the [StateFrmKind::Id] leaves. A variable bound by a later + /// equation continues the chain in that equation's body, which is a different formula and + /// therefore a nested traversal. fn alternation_depth_rec(&self, i: usize, formula: &StateFrm, identifier: &String) -> usize { let equation = &self.equations[i]; + let mut depth = 0; - match &formula.node { - StateFrmKind::Id(id, _) => { - if id == identifier { - 1 - } else { - let (j, inner_equation) = self - .find_equation_by_identifier(id) - .expect("Equation not found for identifier"); - if j > i { - let depth = self.alternation_depth_rec(j, &inner_equation.rhs, identifier); - depth - + (if inner_equation.operator != equation.operator { - 1 // Alternation occurs. - } else { - 0 - }) + formula.visit::<(), _>(|formula| { + match &formula.node { + StateFrmKind::Id(id, _) => { + depth = depth.max(if id == identifier { + 1 } else { - // Only consider nested equations - 0 - } + let (j, inner_equation) = self + .find_equation_by_identifier(id) + .expect("Equation not found for identifier"); + + if j > i { + self.alternation_depth_rec(j, &inner_equation.rhs, identifier) + + usize::from(inner_equation.operator != equation.operator) + } else { + // Only consider nested equations + 0 + } + }); + } + StateFrmKind::Binary { .. } + | StateFrmKind::Modality { .. } + | StateFrmKind::True + | StateFrmKind::False => {} + _ => { + unimplemented!("Cannot determine alternation depth of formula {}", formula) } } - StateFrmKind::Binary { lhs, rhs, .. } => self - .alternation_depth_rec(i, lhs, identifier) - .max(self.alternation_depth_rec(i, rhs, identifier)), - StateFrmKind::Modality { expr, .. } => self.alternation_depth_rec(i, expr, identifier), - StateFrmKind::True | StateFrmKind::False => 0, - _ => { - unimplemented!("Cannot determine alternation depth of formula {}", formula) - } - } + + ControlFlow::Continue(()) + }); + + depth } } @@ -189,12 +198,13 @@ fn add_placeholder_operator(formula: StateFrm, identifier_generator: &mut FreshS fn apply_e(equations: &mut Vec, formula: &StateFrm) { debug!("Applying E to formula: {}", formula); - visit_statefrm::<(), _>(formula, |formula| match &formula.node { - StateFrmKind::FixedPoint { + formula.visit::<(), _>(|formula| { + if let StateFrmKind::FixedPoint { operator, variable, body, - } => { + } = &formula.node + { debug!("Adding equation for variable {}", variable.identifier); // Add the equation with the renamed variable (the span is the same as the original variable). equations.push(Equation { @@ -202,12 +212,10 @@ fn apply_e(equations: &mut Vec, formula: &StateFrm) { variable: variable.clone(), rhs: rhs(body), }); - - Ok(ControlFlow::Continue(())) } - _ => Ok(ControlFlow::Continue(())), - }) - .expect("No error expected during fixpoint equation system construction"); + + ControlFlow::Continue(()) + }); } /// Applies `RHS` to the given formula. @@ -222,7 +230,7 @@ fn apply_e(equations: &mut Vec, formula: &StateFrm) { /// RHS(mu X. f) = X(args) /// RHS(nu X. f) = X(args) fn rhs(formula: &StateFrm) -> StateFrm { - apply_statefrm(formula.clone(), |formula| match &formula.node { + let result = formula.clone().apply::(|formula| match &formula.node { // RHS(mu X. phi) = X(args) StateFrmKind::FixedPoint { variable, .. } => Ok(Some( StateFrmKind::Id( @@ -232,8 +240,12 @@ fn rhs(formula: &StateFrm) -> StateFrm { .into(), )), _ => Ok(None), - }) - .expect("No error expected during RHS extraction") + }); + + match result { + Ok(formula) => formula, + Err(error) => match error {}, + } } /// A generator for fresh state variable names. @@ -249,14 +261,13 @@ impl FreshStateVarGenerator { /// Traverses the given formula to collect all used variable names. pub fn new(formula: &StateFrm) -> Self { let mut used = HashSet::new(); - visit_statefrm::<(), _>(formula, |subformula| { + formula.visit::<(), _>(|subformula| { if let StateFrmKind::FixedPoint { variable, .. } = &subformula.node { used.insert(variable.identifier.clone()); } - Ok(ControlFlow::Continue(())) - }) - .expect("No error expected during visiting"); + ControlFlow::Continue(()) + }); FreshStateVarGenerator { used } } diff --git a/crates/vpg/src/translate.rs b/crates/vpg/src/translate.rs index 32fa6e47f..78998fb15 100644 --- a/crates/vpg/src/translate.rs +++ b/crates/vpg/src/translate.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::convert::Infallible; use std::fmt; use std::ops::ControlFlow; @@ -25,10 +26,7 @@ use merc_syntax::StateFrm; use merc_syntax::StateFrmKind; use merc_syntax::StateFrmOp; use merc_syntax::StateVarDecl; -use merc_syntax::apply_statefrm; -use merc_syntax::visit_action_formula; -use merc_syntax::visit_regular_formula; -use merc_syntax::visit_statefrm; +use merc_syntax::Traverse; use merc_utilities::MercError; use crate::FreshStateVarGenerator; @@ -92,11 +90,13 @@ pub fn translate(lts: &LabelledTransitionSystem, formula: &StateFrm) -> /// Produces a warning for each label that is used in the formula but does not correspond to any label in the LTS. pub fn warn_unknown_action_labels(formula: &StateFrm, labels: &[MultiAction]) { - visit_statefrm::<(), _>(formula, |statefrm| { + // A traversal covers a single node type, so the modalities, the regular formulas they carry + // and the action formulas inside those are three nested traversals. + formula.visit::<(), _>(|statefrm| { if let StateFrmKind::Modality { formula, .. } = &statefrm.node { - visit_regular_formula::<(), _>(formula, |regfrm| { + formula.visit::<(), _>(|regfrm| { if let RegFrmKind::Action(act_frm) = ®frm.node { - visit_action_formula::<(), _>(act_frm, |act_frm| { + act_frm.visit::<(), _>(|act_frm| { if let ActFrmKind::MultAct(action) = &act_frm.node && !labels.contains(action) { @@ -106,17 +106,16 @@ pub fn warn_unknown_action_labels(formula: &StateFrm, labels: &[MultiAction]) { ); } - Ok(ControlFlow::Continue(())) - })?; + ControlFlow::Continue(()) + }); } - Ok(ControlFlow::Continue(())) - })?; + ControlFlow::Continue(()) + }); } - Ok(ControlFlow::Continue(())) - }) - .expect("Failed to visit state formula"); + ControlFlow::Continue(()) + }); } /// Translates regular formulas in modalities to fixpoint equations. @@ -138,7 +137,7 @@ pub fn warn_unknown_action_labels(formula: &StateFrm, labels: &[MultiAction]) { /// phi = (mu I. I || phi) /// ``` pub fn translate_regular_formulas(formula: StateFrm, identifier_generator: &mut FreshStateVarGenerator) -> StateFrm { - apply_statefrm(formula, |subformula| { + let translated = formula.apply::(|subformula| { if let StateFrmKind::Modality { operator, formula, @@ -213,8 +212,12 @@ pub fn translate_regular_formulas(formula: StateFrm, identifier_generator: &mut } Ok(None) - }) - .expect("Failed to visit state formula") + }); + + match translated { + Ok(formula) => formula, + Err(error) => match error {}, + } } /// Convert an iteration regular formula to a fixpoint formula diff --git a/examples/pbes/alloc3.text.pbes b/examples/pbes/alloc3.text.pbes new file mode 100644 index 000000000..7dcf227ed --- /dev/null +++ b/examples/pbes/alloc3.text.pbes @@ -0,0 +1,12 @@ +sort ClientId = struct c1 | c2 | c3; + +map N: Nat; + +eqn N = 5; + +pbes nu Z(busy_ResourceAllocator: Bool, n_ResourceAllocator: Nat, r_Client: Bool, n_Client: Nat, r_Client1: Bool, n_Client1: Nat, r_Client2: Bool, n_Client2: Nat) = + (val(busy_ResourceAllocator && n_ResourceAllocator < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator + 1, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2) || val(r_Client && n_Client < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client + 1, r_Client1, n_Client1, r_Client2, n_Client2) || val(r_Client1 && n_Client1 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1 + 1, r_Client2, n_Client2) || val(r_Client2 && n_Client2 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2 + 1) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client && n_Client == 5) && X0(false, 0, false, 0, r_Client1, n_Client1, r_Client2, n_Client2) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client1 && n_Client1 == 5) && X0(false, 0, r_Client, n_Client, false, 0, r_Client2, n_Client2) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client2 && n_Client2 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, false, 0) || val(!r_Client) && X0(true, n_ResourceAllocator, true, n_Client, r_Client1, n_Client1, r_Client2, n_Client2) || val(!r_Client1) && X0(true, n_ResourceAllocator, r_Client, n_Client, true, n_Client1, r_Client2, n_Client2) || val(!r_Client2) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, true, n_Client2)) && (val(!(busy_ResourceAllocator && n_ResourceAllocator < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator + 1, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2)) && (val(!(r_Client && n_Client < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client + 1, r_Client1, n_Client1, r_Client2, n_Client2)) && (val(!(r_Client1 && n_Client1 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1 + 1, r_Client2, n_Client2)) && (val(!(r_Client2 && n_Client2 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2 + 1)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client && n_Client == 5)) || Z(false, 0, false, 0, r_Client1, n_Client1, r_Client2, n_Client2)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client1 && n_Client1 == 5)) || Z(false, 0, r_Client, n_Client, false, 0, r_Client2, n_Client2)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client2 && n_Client2 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, false, 0)) && (val(!!r_Client) || Z(true, n_ResourceAllocator, true, n_Client, r_Client1, n_Client1, r_Client2, n_Client2)) && (val(!!r_Client1) || Z(true, n_ResourceAllocator, r_Client, n_Client, true, n_Client1, r_Client2, n_Client2)) && (val(!!r_Client2) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, true, n_Client2)); + nu X0(busy_ResourceAllocator: Bool, n_ResourceAllocator: Nat, r_Client: Bool, n_Client: Nat, r_Client1: Bool, n_Client1: Nat, r_Client2: Bool, n_Client2: Nat) = + X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2); + +init Z(false, 0, false, 0, false, 0, false, 0); diff --git a/examples/pbes/alloc7.text.pbes b/examples/pbes/alloc7.text.pbes new file mode 100644 index 000000000..1cf67b21b --- /dev/null +++ b/examples/pbes/alloc7.text.pbes @@ -0,0 +1,12 @@ +sort ClientId = struct c1 | c2 | c3 | c4 | c5 | c6 | c7; + +map N: Nat; + +eqn N = 5; + +pbes nu Z(busy_ResourceAllocator: Bool, n_ResourceAllocator: Nat, r_Client: Bool, n_Client: Nat, r_Client1: Bool, n_Client1: Nat, r_Client2: Bool, n_Client2: Nat, r_Client3: Bool, n_Client3: Nat, r_Client4: Bool, n_Client4: Nat, r_Client5: Bool, n_Client5: Nat, r_Client6: Bool, n_Client6: Nat) = + (val(busy_ResourceAllocator && n_ResourceAllocator < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator + 1, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(r_Client && n_Client < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client + 1, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(r_Client1 && n_Client1 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1 + 1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(r_Client2 && n_Client2 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2 + 1, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(r_Client3 && n_Client3 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3 + 1, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(r_Client4 && n_Client4 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4 + 1, r_Client5, n_Client5, r_Client6, n_Client6) || val(r_Client5 && n_Client5 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5 + 1, r_Client6, n_Client6) || val(r_Client6 && n_Client6 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6 + 1) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client && n_Client == 5) && X0(false, 0, false, 0, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client1 && n_Client1 == 5) && X0(false, 0, r_Client, n_Client, false, 0, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client2 && n_Client2 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, false, 0, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client3 && n_Client3 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, false, 0, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client4 && n_Client4 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, false, 0, r_Client5, n_Client5, r_Client6, n_Client6) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client5 && n_Client5 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, false, 0, r_Client6, n_Client6) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client6 && n_Client6 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, false, 0) || val(!r_Client) && X0(true, n_ResourceAllocator, true, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(!r_Client1) && X0(true, n_ResourceAllocator, r_Client, n_Client, true, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(!r_Client2) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, true, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(!r_Client3) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, true, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(!r_Client4) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, true, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6) || val(!r_Client5) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, true, n_Client5, r_Client6, n_Client6) || val(!r_Client6) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, true, n_Client6)) && (val(!(busy_ResourceAllocator && n_ResourceAllocator < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator + 1, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!(r_Client && n_Client < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client + 1, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!(r_Client1 && n_Client1 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1 + 1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!(r_Client2 && n_Client2 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2 + 1, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!(r_Client3 && n_Client3 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3 + 1, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!(r_Client4 && n_Client4 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4 + 1, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!(r_Client5 && n_Client5 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5 + 1, r_Client6, n_Client6)) && (val(!(r_Client6 && n_Client6 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6 + 1)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client && n_Client == 5)) || Z(false, 0, false, 0, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client1 && n_Client1 == 5)) || Z(false, 0, r_Client, n_Client, false, 0, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client2 && n_Client2 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, false, 0, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client3 && n_Client3 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, false, 0, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client4 && n_Client4 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, false, 0, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client5 && n_Client5 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, false, 0, r_Client6, n_Client6)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client6 && n_Client6 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, false, 0)) && (val(!!r_Client) || Z(true, n_ResourceAllocator, true, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!!r_Client1) || Z(true, n_ResourceAllocator, r_Client, n_Client, true, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!!r_Client2) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, true, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!!r_Client3) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, true, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!!r_Client4) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, true, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6)) && (val(!!r_Client5) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, true, n_Client5, r_Client6, n_Client6)) && (val(!!r_Client6) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, true, n_Client6)); + nu X0(busy_ResourceAllocator: Bool, n_ResourceAllocator: Nat, r_Client: Bool, n_Client: Nat, r_Client1: Bool, n_Client1: Nat, r_Client2: Bool, n_Client2: Nat, r_Client3: Bool, n_Client3: Nat, r_Client4: Bool, n_Client4: Nat, r_Client5: Bool, n_Client5: Nat, r_Client6: Bool, n_Client6: Nat) = + X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6); + +init Z(false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0); diff --git a/examples/pbes/alloc9.srf.text.pbes b/examples/pbes/alloc9.srf.text.pbes new file mode 100644 index 000000000..d68662853 Binary files /dev/null and b/examples/pbes/alloc9.srf.text.pbes differ diff --git a/examples/pbes/alloc9.text.pbes b/examples/pbes/alloc9.text.pbes new file mode 100644 index 000000000..6875a8994 --- /dev/null +++ b/examples/pbes/alloc9.text.pbes @@ -0,0 +1,12 @@ +sort ClientId = struct c1 | c2 | c3 | c4 | c5 | c6 | c7 | c8 | c9; + +map N: Nat; + +eqn N = 5; + +pbes nu Z(busy_ResourceAllocator: Bool, n_ResourceAllocator: Nat, r_Client: Bool, n_Client: Nat, r_Client1: Bool, n_Client1: Nat, r_Client2: Bool, n_Client2: Nat, r_Client3: Bool, n_Client3: Nat, r_Client4: Bool, n_Client4: Nat, r_Client5: Bool, n_Client5: Nat, r_Client6: Bool, n_Client6: Nat, r_Client7: Bool, n_Client7: Nat, r_Client8: Bool, n_Client8: Nat) = + (val(busy_ResourceAllocator && n_ResourceAllocator < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator + 1, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client && n_Client < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client + 1, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client1 && n_Client1 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1 + 1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client2 && n_Client2 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2 + 1, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client3 && n_Client3 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3 + 1, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client4 && n_Client4 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4 + 1, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client5 && n_Client5 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5 + 1, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client6 && n_Client6 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6 + 1, r_Client7, n_Client7, r_Client8, n_Client8) || val(r_Client7 && n_Client7 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7 + 1, r_Client8, n_Client8) || val(r_Client8 && n_Client8 < 5) && X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8 + 1) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client && n_Client == 5) && X0(false, 0, false, 0, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client1 && n_Client1 == 5) && X0(false, 0, r_Client, n_Client, false, 0, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client2 && n_Client2 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, false, 0, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client3 && n_Client3 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, false, 0, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client4 && n_Client4 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, false, 0, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client5 && n_Client5 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, false, 0, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client6 && n_Client6 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, false, 0, r_Client7, n_Client7, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client7 && n_Client7 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, false, 0, r_Client8, n_Client8) || val((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client8 && n_Client8 == 5) && X0(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, false, 0) || val(!r_Client) && X0(true, n_ResourceAllocator, true, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(!r_Client1) && X0(true, n_ResourceAllocator, r_Client, n_Client, true, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(!r_Client2) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, true, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(!r_Client3) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, true, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(!r_Client4) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, true, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(!r_Client5) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, true, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(!r_Client6) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, true, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8) || val(!r_Client7) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, true, n_Client7, r_Client8, n_Client8) || val(!r_Client8) && X0(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, true, n_Client8)) && (val(!(busy_ResourceAllocator && n_ResourceAllocator < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator + 1, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client && n_Client < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client + 1, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client1 && n_Client1 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1 + 1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client2 && n_Client2 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2 + 1, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client3 && n_Client3 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3 + 1, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client4 && n_Client4 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4 + 1, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client5 && n_Client5 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5 + 1, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client6 && n_Client6 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6 + 1, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!(r_Client7 && n_Client7 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7 + 1, r_Client8, n_Client8)) && (val(!(r_Client8 && n_Client8 < 5)) || Z(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8 + 1)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client && n_Client == 5)) || Z(false, 0, false, 0, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client1 && n_Client1 == 5)) || Z(false, 0, r_Client, n_Client, false, 0, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client2 && n_Client2 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, false, 0, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client3 && n_Client3 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, false, 0, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client4 && n_Client4 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, false, 0, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client5 && n_Client5 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, false, 0, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client6 && n_Client6 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, false, 0, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client7 && n_Client7 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, false, 0, r_Client8, n_Client8)) && (val(!((busy_ResourceAllocator && n_ResourceAllocator == 5) && r_Client8 && n_Client8 == 5)) || Z(false, 0, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, false, 0)) && (val(!!r_Client) || Z(true, n_ResourceAllocator, true, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client1) || Z(true, n_ResourceAllocator, r_Client, n_Client, true, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client2) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, true, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client3) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, true, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client4) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, true, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client5) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, true, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client6) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, true, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client7) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, true, n_Client7, r_Client8, n_Client8)) && (val(!!r_Client8) || Z(true, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, true, n_Client8)); + nu X0(busy_ResourceAllocator: Bool, n_ResourceAllocator: Nat, r_Client: Bool, n_Client: Nat, r_Client1: Bool, n_Client1: Nat, r_Client2: Bool, n_Client2: Nat, r_Client3: Bool, n_Client3: Nat, r_Client4: Bool, n_Client4: Nat, r_Client5: Bool, n_Client5: Nat, r_Client6: Bool, n_Client6: Nat, r_Client7: Bool, n_Client7: Nat, r_Client8: Bool, n_Client8: Nat) = + X0(busy_ResourceAllocator, n_ResourceAllocator, r_Client, n_Client, r_Client1, n_Client1, r_Client2, n_Client2, r_Client3, n_Client3, r_Client4, n_Client4, r_Client5, n_Client5, r_Client6, n_Client6, r_Client7, n_Client7, r_Client8, n_Client8); + +init Z(false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0, false, 0); diff --git a/examples/pbes/dining8.text.pbes b/examples/pbes/dining8.text.pbes new file mode 100644 index 000000000..5f9f79a61 --- /dev/null +++ b/examples/pbes/dining8.text.pbes @@ -0,0 +1,48 @@ +sort Phil = struct p1 | p2 | p3; + Fork = struct f1 | f2 | f3; + Enum4 = struct e3_4 | e2_4 | e1_4 | e0_4; + +map lf,rf: Phil -> Fork; + C4_: Enum4 # Fork # Fork # Fork # Fork -> Fork; + C4_1: Enum4 # Pos # Pos # Pos # Pos -> Pos; + C4_2: Enum4 # Phil # Phil # Phil # Phil -> Phil; + C4_3: Enum4 # Bool # Bool # Bool # Bool -> Bool; + +var x1,y4,y3,y2,y1: Fork; + e1,e2,e3,e4: Enum4; + x2,y8,y7,y6,y5: Pos; + x3,y12,y11,y10,y9: Phil; + x4,y16,y15,y14,y13: Bool; +eqn lf(p1) = f1; + lf(p2) = f2; + lf(p3) = f3; + rf(p1) = f3; + rf(p2) = f1; + rf(p3) = f2; + C4_(e1, x1, x1, x1, x1) = x1; + C4_(e3_4, y4, y3, y2, y1) = y4; + C4_(e2_4, y4, y3, y2, y1) = y3; + C4_(e1_4, y4, y3, y2, y1) = y2; + C4_(e0_4, y4, y3, y2, y1) = y1; + C4_1(e2, x2, x2, x2, x2) = x2; + C4_1(e3_4, y8, y7, y6, y5) = y8; + C4_1(e2_4, y8, y7, y6, y5) = y7; + C4_1(e1_4, y8, y7, y6, y5) = y6; + C4_1(e0_4, y8, y7, y6, y5) = y5; + C4_2(e3, x3, x3, x3, x3) = x3; + C4_2(e3_4, y12, y11, y10, y9) = y12; + C4_2(e2_4, y12, y11, y10, y9) = y11; + C4_2(e1_4, y12, y11, y10, y9) = y10; + C4_2(e0_4, y12, y11, y10, y9) = y9; + C4_3(e4, x4, x4, x4, x4) = x4; + C4_3(e3_4, y16, y15, y14, y13) = y16; + C4_3(e2_4, y16, y15, y14, y13) = y15; + C4_3(e1_4, y16, y15, y14, y13) = y14; + C4_3(e0_4, y16, y15, y14, y13) = y13; + +pbes nu Z(s1_P_Fork,s2_P_Fork,s3_P_Fork,s4_P_Phil,s5_P_Phil,s6_P_Phil: Pos) = + (val(s4_P_Phil == 4) && X0(s1_P_Fork, s2_P_Fork, s3_P_Fork, 5, s5_P_Phil, s6_P_Phil) || val(s5_P_Phil == 4) && X0(s1_P_Fork, s2_P_Fork, s3_P_Fork, s4_P_Phil, 5, s6_P_Phil) || val(s6_P_Phil == 4) && X0(s1_P_Fork, s2_P_Fork, s3_P_Fork, s4_P_Phil, s5_P_Phil, 5) || val(s3_P_Fork == 2 && s4_P_Phil == 6) && X0(s1_P_Fork, s2_P_Fork, 1, 1, s5_P_Phil, s6_P_Phil) || val(s3_P_Fork == 2 && s4_P_Phil == 5) && X0(s1_P_Fork, s2_P_Fork, 1, 7, s5_P_Phil, s6_P_Phil) || val(s3_P_Fork == 2 && s6_P_Phil == 7) && X0(s1_P_Fork, s2_P_Fork, 1, s4_P_Phil, s5_P_Phil, 1) || val(s3_P_Fork == 2 && s6_P_Phil == 5) && X0(s1_P_Fork, s2_P_Fork, 1, s4_P_Phil, s5_P_Phil, 6) || val(s3_P_Fork == 1 && s4_P_Phil == 2) && X0(s1_P_Fork, s2_P_Fork, 2, 4, s5_P_Phil, s6_P_Phil) || val(s3_P_Fork == 1 && s4_P_Phil == 1) && X0(s1_P_Fork, s2_P_Fork, 2, 3, s5_P_Phil, s6_P_Phil) || val(s3_P_Fork == 1 && s6_P_Phil == 3) && X0(s1_P_Fork, s2_P_Fork, 2, s4_P_Phil, s5_P_Phil, 4) || val(s3_P_Fork == 1 && s6_P_Phil == 1) && X0(s1_P_Fork, s2_P_Fork, 2, s4_P_Phil, s5_P_Phil, 2) || val(s2_P_Fork == 2 && s5_P_Phil == 7) && X0(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, 1, s6_P_Phil) || val(s2_P_Fork == 2 && s5_P_Phil == 5) && X0(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, 6, s6_P_Phil) || val(s2_P_Fork == 2 && s6_P_Phil == 6) && X0(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, s5_P_Phil, 1) || val(s2_P_Fork == 2 && s6_P_Phil == 5) && X0(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, s5_P_Phil, 7) || val(s2_P_Fork == 1 && s5_P_Phil == 3) && X0(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, 4, s6_P_Phil) || val(s2_P_Fork == 1 && s5_P_Phil == 1) && X0(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, 2, s6_P_Phil) || val(s2_P_Fork == 1 && s6_P_Phil == 2) && X0(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, s5_P_Phil, 4) || val(s2_P_Fork == 1 && s6_P_Phil == 1) && X0(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, s5_P_Phil, 3) || val(s1_P_Fork == 2 && s4_P_Phil == 7) && X0(1, s2_P_Fork, s3_P_Fork, 1, s5_P_Phil, s6_P_Phil) || val(s1_P_Fork == 2 && s4_P_Phil == 5) && X0(1, s2_P_Fork, s3_P_Fork, 6, s5_P_Phil, s6_P_Phil) || val(s1_P_Fork == 2 && s5_P_Phil == 6) && X0(1, s2_P_Fork, s3_P_Fork, s4_P_Phil, 1, s6_P_Phil) || val(s1_P_Fork == 2 && s5_P_Phil == 5) && X0(1, s2_P_Fork, s3_P_Fork, s4_P_Phil, 7, s6_P_Phil) || val(s1_P_Fork == 1 && s4_P_Phil == 3) && X0(2, s2_P_Fork, s3_P_Fork, 4, s5_P_Phil, s6_P_Phil) || val(s1_P_Fork == 1 && s4_P_Phil == 1) && X0(2, s2_P_Fork, s3_P_Fork, 2, s5_P_Phil, s6_P_Phil) || val(s1_P_Fork == 1 && s5_P_Phil == 2) && X0(2, s2_P_Fork, s3_P_Fork, s4_P_Phil, 4, s6_P_Phil) || val(s1_P_Fork == 1 && s5_P_Phil == 1) && X0(2, s2_P_Fork, s3_P_Fork, s4_P_Phil, 3, s6_P_Phil)) && (val(!(s4_P_Phil == 4)) || Z(s1_P_Fork, s2_P_Fork, s3_P_Fork, 5, s5_P_Phil, s6_P_Phil)) && (val(!(s5_P_Phil == 4)) || Z(s1_P_Fork, s2_P_Fork, s3_P_Fork, s4_P_Phil, 5, s6_P_Phil)) && (val(!(s6_P_Phil == 4)) || Z(s1_P_Fork, s2_P_Fork, s3_P_Fork, s4_P_Phil, s5_P_Phil, 5)) && (val(!(s3_P_Fork == 2 && s4_P_Phil == 6)) || Z(s1_P_Fork, s2_P_Fork, 1, 1, s5_P_Phil, s6_P_Phil)) && (val(!(s3_P_Fork == 2 && s4_P_Phil == 5)) || Z(s1_P_Fork, s2_P_Fork, 1, 7, s5_P_Phil, s6_P_Phil)) && (val(!(s3_P_Fork == 2 && s6_P_Phil == 7)) || Z(s1_P_Fork, s2_P_Fork, 1, s4_P_Phil, s5_P_Phil, 1)) && (val(!(s3_P_Fork == 2 && s6_P_Phil == 5)) || Z(s1_P_Fork, s2_P_Fork, 1, s4_P_Phil, s5_P_Phil, 6)) && (val(!(s3_P_Fork == 1 && s4_P_Phil == 2)) || Z(s1_P_Fork, s2_P_Fork, 2, 4, s5_P_Phil, s6_P_Phil)) && (val(!(s3_P_Fork == 1 && s4_P_Phil == 1)) || Z(s1_P_Fork, s2_P_Fork, 2, 3, s5_P_Phil, s6_P_Phil)) && (val(!(s3_P_Fork == 1 && s6_P_Phil == 3)) || Z(s1_P_Fork, s2_P_Fork, 2, s4_P_Phil, s5_P_Phil, 4)) && (val(!(s3_P_Fork == 1 && s6_P_Phil == 1)) || Z(s1_P_Fork, s2_P_Fork, 2, s4_P_Phil, s5_P_Phil, 2)) && (val(!(s2_P_Fork == 2 && s5_P_Phil == 7)) || Z(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, 1, s6_P_Phil)) && (val(!(s2_P_Fork == 2 && s5_P_Phil == 5)) || Z(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, 6, s6_P_Phil)) && (val(!(s2_P_Fork == 2 && s6_P_Phil == 6)) || Z(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, s5_P_Phil, 1)) && (val(!(s2_P_Fork == 2 && s6_P_Phil == 5)) || Z(s1_P_Fork, 1, s3_P_Fork, s4_P_Phil, s5_P_Phil, 7)) && (val(!(s2_P_Fork == 1 && s5_P_Phil == 3)) || Z(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, 4, s6_P_Phil)) && (val(!(s2_P_Fork == 1 && s5_P_Phil == 1)) || Z(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, 2, s6_P_Phil)) && (val(!(s2_P_Fork == 1 && s6_P_Phil == 2)) || Z(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, s5_P_Phil, 4)) && (val(!(s2_P_Fork == 1 && s6_P_Phil == 1)) || Z(s1_P_Fork, 2, s3_P_Fork, s4_P_Phil, s5_P_Phil, 3)) && (val(!(s1_P_Fork == 2 && s4_P_Phil == 7)) || Z(1, s2_P_Fork, s3_P_Fork, 1, s5_P_Phil, s6_P_Phil)) && (val(!(s1_P_Fork == 2 && s4_P_Phil == 5)) || Z(1, s2_P_Fork, s3_P_Fork, 6, s5_P_Phil, s6_P_Phil)) && (val(!(s1_P_Fork == 2 && s5_P_Phil == 6)) || Z(1, s2_P_Fork, s3_P_Fork, s4_P_Phil, 1, s6_P_Phil)) && (val(!(s1_P_Fork == 2 && s5_P_Phil == 5)) || Z(1, s2_P_Fork, s3_P_Fork, s4_P_Phil, 7, s6_P_Phil)) && (val(!(s1_P_Fork == 1 && s4_P_Phil == 3)) || Z(2, s2_P_Fork, s3_P_Fork, 4, s5_P_Phil, s6_P_Phil)) && (val(!(s1_P_Fork == 1 && s4_P_Phil == 1)) || Z(2, s2_P_Fork, s3_P_Fork, 2, s5_P_Phil, s6_P_Phil)) && (val(!(s1_P_Fork == 1 && s5_P_Phil == 2)) || Z(2, s2_P_Fork, s3_P_Fork, s4_P_Phil, 4, s6_P_Phil)) && (val(!(s1_P_Fork == 1 && s5_P_Phil == 1)) || Z(2, s2_P_Fork, s3_P_Fork, s4_P_Phil, 3, s6_P_Phil)); + nu X0(s1_P_Fork,s2_P_Fork,s3_P_Fork,s4_P_Phil,s5_P_Phil,s6_P_Phil: Pos) = + X0(s1_P_Fork, s2_P_Fork, s3_P_Fork, s4_P_Phil, s5_P_Phil, s6_P_Phil); + +init Z(1, 1, 1, 1, 1, 1); diff --git a/tools/mcrl2/Cargo.lock b/tools/mcrl2/Cargo.lock index bbe9068ef..a455ff78e 100644 --- a/tools/mcrl2/Cargo.lock +++ b/tools/mcrl2/Cargo.lock @@ -342,7 +342,7 @@ dependencies = [ "cxxbridge-cmd", "cxxbridge-flags", "cxxbridge-macro", - "foldhash", + "foldhash 0.2.0", "link-cplusplus", ] @@ -572,6 +572,12 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -658,6 +664,9 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] [[package]] name = "hashbrown" @@ -667,7 +676,7 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2 0.2.21", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -925,7 +934,7 @@ dependencies = [ [[package]] name = "mcrl2-sys" version = "1.0.0" -source = "git+https://github.com/MERCorg/mCRL2-sys?rev=4e01fe2a60c5278e05527d0b52d7058e998eec60#4e01fe2a60c5278e05527d0b52d7058e998eec60" +source = "git+https://github.com/MERCorg/mCRL2-sys?rev=6302f19246ba5ecfd16702226a1a70ed74022f58#6302f19246ba5ecfd16702226a1a70ed74022f58" dependencies = [ "cargo-emit", "cc", @@ -946,29 +955,15 @@ version = "1.0.0" dependencies = [ "clap", "env_logger", - "itertools 0.15.0", - "log", "mcrl2", - "merc_collections", - "merc_data", "merc_explore", - "merc_io", + "merc_lps", "merc_lts", - "merc_reduction", "merc_symbolic", - "merc_syntax", "merc_tools", "merc_unsafety", "merc_utilities", "oxidd", - "rand", - "rayon", - "rustc-hash 2.1.3", - "serde", - "serde_json", - "streaming-iterator", - "tempfile", - "thiserror", ] [[package]] @@ -976,26 +971,18 @@ name = "merc-pbes" version = "1.0.0" dependencies = [ "clap", + "duct", "env_logger", - "itertools 0.15.0", "log", "mcrl2", - "merc_collections", "merc_explore", - "merc_io", - "merc_lts", - "merc_symbolic", + "merc_pbes", "merc_tools", "merc_unsafety", "merc_utilities", "merc_vpg", "oxidd", - "rand", - "rayon", - "serde", - "serde_json", - "streaming-iterator", - "thiserror", + "which", ] [[package]] @@ -1089,6 +1076,25 @@ dependencies = [ "thiserror", ] +[[package]] +name = "merc_lps" +version = "1.0.0" +dependencies = [ + "log", + "mcrl2", + "merc_explore", + "merc_io", + "merc_lts", + "merc_reduction", + "merc_symbolic", + "merc_syntax", + "merc_unsafety", + "merc_utilities", + "oxidd", + "rayon", + "tempfile", +] + [[package]] name = "merc_lts" version = "3.0.0" @@ -1132,6 +1138,28 @@ dependencies = [ "rand", ] +[[package]] +name = "merc_pbes" +version = "1.0.0" +dependencies = [ + "duct", + "itertools 0.15.0", + "log", + "mcrl2", + "merc_explore", + "merc_io", + "merc_lts", + "merc_symbolic", + "merc_syntax", + "merc_unsafety", + "merc_utilities", + "merc_vpg", + "oxidd", + "petgraph", + "rand", + "test-case", +] + [[package]] name = "merc_pest_consume" version = "2.0.0" @@ -1701,6 +1729,18 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", + "serde", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2137,6 +2177,39 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "test-case" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2550dd13afcd286853192af8601920d959b14c401fcece38071d53bf0768a8" +dependencies = [ + "test-case-macros", +] + +[[package]] +name = "test-case-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adcb7fd841cd518e279be3d5a3eb0636409487998a4aff22f3de87b81e88384f" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "test-case-macros" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c89e72a01ed4c579669add59014b9a524d609c0c88c6a585ce37485879f6ffb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "test-case-core", +] + [[package]] name = "thiserror" version = "2.0.20" @@ -2321,6 +2394,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "which" +version = "8.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3ef584124b911bcc3875c2f1472e80f24361ceb789bd1c62b3e9a3df9ff43c" +dependencies = [ + "libc", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/tools/mcrl2/Cargo.toml b/tools/mcrl2/Cargo.toml index dc1b7d999..aa61a3688 100644 --- a/tools/mcrl2/Cargo.toml +++ b/tools/mcrl2/Cargo.toml @@ -15,6 +15,8 @@ resolver = "3" members = [ "crates/mcrl2", "crates/mcrl2-macros", + "crates/merc_lps", + "crates/merc_pbes", "crates/xtask", "lps", "pbes", @@ -23,20 +25,30 @@ exclude = [ "crates/mCRL2-sys", ] +[workspace.lints.rustdoc] +# Several public functions/types intentionally link to crate-internal items +# from their doc comments (e.g. to explain an implementation detail without +# exposing it). Keep the links themselves (so a rename still trips the +# separate, still-denied `broken_intra_doc_links` lint) but don't fail the +# build just because the linked item isn't publicly reachable. +private_intra_doc_links = "allow" + [workspace.dependencies] clap = { version = "4.6", features = ["derive"] } -env_logger = "0.11" +env_logger = { version = "0.11", features = ["kv"] } itertools = "0.15" -log = "0.4" +log = { version = "0.4", features = ["kv"] } parking_lot = "0.12" +petgraph = "0.8" rand = "0.10" rayon = "1.12" rustc-hash = "2.1" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" streaming-iterator = "0.1" -thiserror = "2.0" tempfile = "3.27" +test-case = "3" +thiserror = "2.0" # Dependencies used in procedural macros. proc-macro2 = "1.0" @@ -45,17 +57,20 @@ syn = { version = "3.0", features = ["full"] } # Dependencies used in xtask duct = "1.1" +which = "8.0" # The workspace libraries. mcrl2 = { path = "crates/mcrl2" } mcrl2-macros = { path = "crates/mcrl2-macros" } -mcrl2-sys = { git = "https://github.com/MERCorg/mCRL2-sys", rev = "4e01fe2a60c5278e05527d0b52d7058e998eec60" } +mcrl2-sys = { git = "https://github.com/MERCorg/mCRL2-sys", rev = "6302f19246ba5ecfd16702226a1a70ed74022f58" } merc_aterm = { path = "../../crates/aterm" } merc_collections = { path = "../../crates/collections" } merc_data = { path = "../../crates/data" } merc_explore = { path = "../../crates/explore", features = ["clap"] } merc_io = { path = "../../crates/io" } +merc_lps = { path = "crates/merc_lps" } merc_lts = { path = "../../crates/lts", features = ["clap"] } +merc_pbes = { path = "crates/merc_pbes" } merc_reduction = { path = "../../crates/reduction" } merc_symbolic = { path = "../../crates/symbolic", features = ["clap"] } merc_tools = { path = "../../crates/tools" } @@ -67,7 +82,6 @@ merc_vpg = { path = "../../crates/vpg" } oxidd = { version = "0.12", features = ["manager-pointer"] } -# Use a local version of mCRL2-sys for development. # [patch."https://github.com/MERCorg/mCRL2-sys"] # mcrl2-sys = { path = "crates/mCRL2-sys" } diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs index dec51ce22..480a56a1c 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm.rs @@ -22,8 +22,8 @@ use mcrl2_sys::cxx::UniquePtr; use merc_unsafety::ProtectionIndex; use merc_utilities::PhantomUnsend; +use super::THREAD_TERM_POOL; use crate::atermpp::SymbolRef; -use crate::atermpp::THREAD_TERM_POOL; use super::global_aterm_pool::ATermPtr; use super::global_aterm_pool::SEND_PROTECTION_SET; @@ -50,9 +50,9 @@ pub struct ATermRef<'a> { marker: PhantomData<&'a ()>, } -/// These are safe because terms are never modified. Garbage collection is -/// always performed with exclusive access and uses relaxed atomics to perform -/// some interior mutability. +// SAFETY: terms are never modified. Garbage collection is always performed +// with exclusive access and uses relaxed atomics to perform some interior +// mutability. unsafe impl Send for ATermRef<'_> {} unsafe impl Sync for ATermRef<'_> {} diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_int.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_int.rs index db6fe1bf6..d1c1c7a3f 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_int.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_int.rs @@ -18,8 +18,8 @@ mod inner { use crate::ATerm; use crate::ATermRef; use crate::Markable; - use crate::THREAD_TERM_POOL; use crate::Todo; + use crate::atermpp::THREAD_TERM_POOL; use crate::is_aterm_int; /// Represents an atermpp::aterm_int from the mCRL2 toolset. @@ -44,7 +44,8 @@ mod inner { } } -pub use inner::*; +pub use inner::ATermInt; +pub use inner::ATermIntRef; impl fmt::Display for ATermInt { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_list.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_list.rs index 717afd7ce..fe369f566 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_list.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_list.rs @@ -4,9 +4,9 @@ use std::marker::PhantomData; use itertools::Itertools; use mcrl2_sys::atermpp::ffi::_aterm; +use super::THREAD_TERM_POOL; use crate::ATerm; use crate::ATermRef; -use crate::THREAD_TERM_POOL; pub struct ATermList { term: ATerm, diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_string.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_string.rs index 799147e21..6fdc27087 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_string.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/aterm_string.rs @@ -33,7 +33,25 @@ mod inner { } } -pub use inner::*; +pub use inner::ATermString; +pub use inner::ATermStringRef; + +impl ATermStringRef<'static> { + /// Creates a reference to the maximally shared aterm_string at `term`. + /// + /// Two occurrences of the same name are the same term, so the resulting + /// reference can be used as a hash key that identifies a name without + /// rendering it to a `String`. + /// + /// # Safety + /// + /// The term at `term` must stay live for the whole of `'static`, i.e. for as + /// long as the returned reference is reachable; see [`crate::ATermRef`]. + pub unsafe fn from_address(term: *const crate::_aterm) -> ATermStringRef<'static> { + // SAFETY: the caller upholds that the term stays live for `'static`. + ATermStringRef::new(unsafe { ATermRef::new(term) }) + } +} impl fmt::Display for ATermString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/convert.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/convert.rs index a1f80a459..36af20a74 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/convert.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/convert.rs @@ -149,7 +149,7 @@ mod tests { use super::mcrl2_aterm_to_merc; use super::merc_aterm_to_mcrl2; - use crate::THREAD_TERM_POOL as MCRL2_POOL; + use crate::atermpp::THREAD_TERM_POOL as MCRL2_POOL; /// Build a simple merc term `f(g(a), b)` and verify it round-trips through /// the merc → mcrl2 → merc pipeline without structural change. diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/global_aterm_pool.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/global_aterm_pool.rs index d2957a068..297b612e2 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/global_aterm_pool.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/global_aterm_pool.rs @@ -2,8 +2,9 @@ use std::fmt::Debug; use std::pin::Pin; use std::sync::Arc; use std::sync::LazyLock; +use std::sync::Once; -use log::info; +use log::debug; use log::trace; use parking_lot::Mutex; @@ -12,6 +13,7 @@ use mcrl2_sys::atermpp::ffi::mcrl2_aterm_mark_address; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_capacity; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_enable_automatic_garbage_collection; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_enable_automatic_resize; +use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_register_mark_callback; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_size; use merc_unsafety::ProtectionSet; @@ -146,7 +148,7 @@ impl GlobalTermPool { trace!("Marked send term {:?}, index {root}", term.ptr); } - info!("Collecting garbage \n{:?}", self); + debug!("Collecting garbage \n{:?}", self); } /// Counts the number of terms in all protection sets. @@ -167,6 +169,11 @@ impl GlobalTermPool { result } + /// Returns the number of registered (live) thread term pools, at least one. + fn num_thread_pools(&self) -> usize { + self.thread_protection_sets.iter().flatten().count().max(1) + } + /// Returns the number of terms in the pool. pub(super) fn len(&self) -> usize { mcrl2_aterm_pool_size() @@ -235,3 +242,35 @@ pub(crate) fn mark_protection_sets(todo: Pin<&mut ffi::term_mark_stack>) { pub(crate) fn protection_set_size() -> usize { GLOBAL_TERM_POOL.lock().protection_set_size() } + +/// Returns the number of registered (live) thread term pools, at least one. +pub(crate) fn num_thread_pools() -> usize { + GLOBAL_TERM_POOL.lock().num_thread_pools() +} + +/// Guards the one-time registration of the mark callback below. +static MARK_CALLBACK: Once = Once::new(); + +/// Registers [`mark_protection_sets`] with the mCRL2 aterm pool, exactly once +/// for the whole process. +/// +/// Registering is deliberately not done per thread: the callback already walks +/// the protection sets of *every* thread, while the pool invokes each registered +/// callback once per collection. One registration per thread would therefore make +/// a single collection mark every protection set once per thread, i.e. quadratic +/// in the number of threads. +/// +/// Must be called without holding [`GLOBAL_TERM_POOL`], since registering takes +/// the shared aterm pool lock while a collecting thread takes those in the +/// opposite order. +pub(crate) fn register_mark_callback() { + MARK_CALLBACK.call_once(|| { + // The registration is deliberately leaked. It has to stay alive for the + // rest of the process, and the mCRL2 side deregisters a callback from + // whichever thread drops it rather than the one that registered it. + std::mem::forget(mcrl2_aterm_pool_register_mark_callback( + mark_protection_sets, + protection_set_size, + )); + }); +} diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/mod.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/mod.rs index 9a1cb17d4..0f14bc2d8 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/mod.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/mod.rs @@ -11,15 +11,36 @@ mod random_term; mod symbol; mod thread_aterm_pool; -pub use aterm::*; -pub use aterm_int::*; -pub use aterm_list::*; -pub use aterm_string::*; -pub(crate) use busy_forbidden::*; -pub(crate) use convert::mcrl2_aterm_to_merc; +pub use aterm::ATerm; +pub use aterm::ATermArgs; +pub use aterm::ATermRef; +pub use aterm::ATermSend; +pub use aterm::TermIterator; + +pub use aterm_int::ATermInt; +pub use aterm_int::ATermIntRef; +pub(crate) use aterm_int::is_aterm_int; + +pub use aterm_list::ATermList; +pub use aterm_list::ATermListIter; +pub use aterm_list::ATermListIterRef; +pub use aterm_list::ATermListRef; + +pub use aterm_string::ATermString; +pub use aterm_string::ATermStringRef; +pub(crate) use aterm_string::is_aterm_string; + +pub(crate) use busy_forbidden::BfTermPool; +pub(crate) use busy_forbidden::BfTermPoolThreadWrite; + pub use convert::merc_aterm_to_mcrl2; -pub use markable::*; -pub use protected::*; -pub(crate) use random_term::*; -pub use symbol::*; -pub(crate) use thread_aterm_pool::*; + +pub use markable::Markable; +pub use markable::Todo; + +pub use protected::Protected; + +pub use symbol::Symbol; +pub use symbol::SymbolRef; + +pub(crate) use thread_aterm_pool::THREAD_TERM_POOL; diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/symbol.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/symbol.rs index b7060c941..f52da5dc9 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/symbol.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/symbol.rs @@ -7,13 +7,13 @@ use std::hash::Hash; use std::hash::Hasher; use std::ops::Deref; +use mcrl2_sys::atermpp::ffi; use mcrl2_sys::atermpp::ffi::mcrl2_function_symbol_drop; use mcrl2_sys::atermpp::ffi::mcrl2_function_symbol_get_arity; use mcrl2_sys::atermpp::ffi::mcrl2_function_symbol_get_name; use mcrl2_sys::atermpp::ffi::mcrl2_function_symbol_protect; -use mcrl2_sys::atermpp::ffi::{self}; -use crate::THREAD_TERM_POOL; +use super::THREAD_TERM_POOL; /// A Symbol references to an aterm function symbol, which has a name and an arity. #[derive(Hash, PartialEq, Eq, PartialOrd, Ord)] diff --git a/tools/mcrl2/crates/mcrl2/src/atermpp/thread_aterm_pool.rs b/tools/mcrl2/crates/mcrl2/src/atermpp/thread_aterm_pool.rs index 32d65adbc..fbf0bd874 100644 --- a/tools/mcrl2/crates/mcrl2/src/atermpp/thread_aterm_pool.rs +++ b/tools/mcrl2/crates/mcrl2/src/atermpp/thread_aterm_pool.rs @@ -2,8 +2,10 @@ use core::fmt; use std::borrow::Borrow; use std::cell::Cell; use std::cell::RefCell; -use std::mem::ManuallyDrop; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use log::debug; use log::trace; @@ -14,17 +16,17 @@ use mcrl2_sys::atermpp::ffi::mcrl2_aterm_create_int; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_empty_list_function_symbol; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_from_string; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_list_function_symbol; -use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_capacity; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_collect_garbage; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_print_metrics; -use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_register_mark_callback; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_resize; +use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_resize_is_needed; use mcrl2_sys::atermpp::ffi::mcrl2_aterm_pool_size; use mcrl2_sys::atermpp::ffi::mcrl2_function_symbol_create; use mcrl2_sys::cxx::Exception; -use mcrl2_sys::cxx::UniquePtr; + use merc_unsafety::ProtectionIndex; use merc_unsafety::ProtectionSet; +use merc_utilities::debug_trace; use crate::ATerm; use crate::ATermRef; @@ -37,11 +39,58 @@ use super::global_aterm_pool::ATermPtr; use super::global_aterm_pool::GLOBAL_TERM_POOL; use super::global_aterm_pool::SharedContainerProtectionSet; use super::global_aterm_pool::SharedProtectionSet; -use super::global_aterm_pool::mark_protection_sets; -use super::global_aterm_pool::protection_set_size; - -/// The number of times before garbage collection is tested again. -const TEST_GC_INTERVAL: usize = 100; +use super::global_aterm_pool::num_thread_pools; +use super::global_aterm_pool::register_mark_callback; + +/// The number of terms that the pool must contain before garbage collection is +/// considered at all, since collecting a small pool is not worth its cost. +const MIN_TERMS_UNTIL_GC: usize = 1_000_000; + +/// The smallest number of terms a thread creates before consulting the shared +/// budget again, which bounds how much the threads contend on it. +const MIN_GC_CHUNK: usize = 100; + +/// The largest such number. Bounded because the same interval also governs how +/// long a hash table resize can be postponed, see [`ThreadTermPool::protect_with`]. +const MAX_GC_CHUNK: usize = 10_000; + +/// The pool size at which the next garbage collection should be triggered. +/// +/// This threshold is global rather than per thread, since a collection performed +/// by one thread reclaims the garbage of all of them. With a per thread threshold +/// the collecting thread lowers only its own, after which every other thread still +/// exceeds its stale threshold and collects the very same (already collected) pool +/// again in turn. +static SIZE_UNTIL_GC: AtomicUsize = AtomicUsize::new(MIN_TERMS_UNTIL_GC); + +/// The number of terms a single thread may create before it compares the pool size +/// against [`SIZE_UNTIL_GC`] again, i.e. this thread's share of the budget that is +/// left until the next collection. +static GC_CHUNK: AtomicUsize = AtomicUsize::new(MIN_GC_CHUNK); + +/// Set while some thread is performing a garbage collection, so that the other +/// threads skip theirs instead of queueing up behind it. +static GC_IN_PROGRESS: AtomicBool = AtomicBool::new(false); + +/// Recomputes the global garbage collection budget from the current pool size, +/// which is done after every collection. +fn reset_gc_budget() { + let size = mcrl2_aterm_pool_size(); + + // Collect again once the pool has roughly doubled. + // + // Note that the capacity cannot be used for this, since it sums the capacities + // of the storages for every arity while a term only occupies one of them. The + // pool size therefore stays well below the capacity and never reaches it. + let until = size.saturating_mul(2).max(MIN_TERMS_UNTIL_GC); + SIZE_UNTIL_GC.store(until, Ordering::Relaxed); + + // Divide the remaining headroom over the registered threads. Every thread + // counting down a fixed interval instead would make the threads together + // check (and thereby contend on the pool size) a factor `threads` too often. + let chunk = until.saturating_sub(size) / num_thread_pools(); + GC_CHUNK.store(chunk.clamp(MIN_GC_CHUNK, MAX_GC_CHUNK), Ordering::Relaxed); +} thread_local! { /// This is the thread specific term pool that manages the protection sets. @@ -64,27 +113,26 @@ pub(crate) struct ThreadTermPool { /// Function symbols to represent 'DataAppl' with any number of arguments. data_appl: RefCell>, - /// We need to periodically test for garbage collection and this is only - /// allowed outside of a shared lock section. Therefore, we count - /// (arbitrarily) to reduce the amount of this is checked. + /// Counts down this thread's share of the budget until the next garbage + /// collection, see [`GC_CHUNK`]. Testing for garbage collection is only allowed + /// outside of a shared lock section, and counting keeps the test itself off the + /// hot path. gc_counter: Cell, - /// Keeps track of the maximum size the term pool should reach before - /// triggering garbage collection. - size_until_gc: Cell, - /// Temporary storage for arguments when creating terms. arguments: RefCell>, - - /// This is only used to keep the callback alive. - _callback: ManuallyDrop>, } impl ThreadTermPool { pub fn new() -> ThreadTermPool { - // Register a protection set into the global set. + // Register a protection set into the global set. The lock must be released + // again before registering the mark callback below, see + // `register_mark_callback`. let (protection_set, container_protection_set, index) = GLOBAL_TERM_POOL.lock().register_thread_term_pool(); + // Only the first thread actually registers, the callback is global. + register_mark_callback(); + ThreadTermPool { protection_set, container_protection_set, @@ -92,23 +140,49 @@ impl ThreadTermPool { // SAFETY: the FFI returns the live built-in list / empty-list function symbols. list_symbol: unsafe { Symbol::from_ptr(mcrl2_aterm_list_function_symbol()) }, empty_list_symbol: unsafe { Symbol::from_ptr(mcrl2_aterm_empty_list_function_symbol()) }, - gc_counter: Cell::new(TEST_GC_INTERVAL), - size_until_gc: Cell::new(mcrl2_aterm_pool_capacity()), + gc_counter: Cell::new(GC_CHUNK.load(Ordering::Relaxed)), data_appl: RefCell::new(vec![]), arguments: RefCell::new(vec![]), - _callback: ManuallyDrop::new(mcrl2_aterm_pool_register_mark_callback( - mark_protection_sets, - protection_set_size, - )), } } /// Trigger a garbage collection explicitly. + /// + /// Note that `mcrl2_aterm_pool_collect_garbage` enables garbage collection for + /// the duration of the call, since the pool ignores every collection—including + /// explicit ones—while it is disabled (see `aterm_pool::collect_impl`). It is + /// disabled in the global term pool to keep the pool from collecting on its own + /// from inside a shared section. pub fn collect(&self) { debug!("Collecting mCRL2 aterm pool garbage"); + mcrl2_aterm_pool_collect_garbage(); - // Garbage collection was performed, so we can reset the size limit. - self.size_until_gc.set(mcrl2_aterm_pool_capacity()); + + // Garbage collection was performed, so we can reset the budget. + reset_gc_budget(); + } + + /// Performs a garbage collection, unless another thread is already collecting + /// or has collected since this thread observed that the pool exceeded + /// [`SIZE_UNTIL_GC`]. + fn collect_if_needed(&self) { + // Claim the collection. Threads that lose this race skip their collection + // entirely: the one that is running reclaims their garbage as well. + if GC_IN_PROGRESS + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Relaxed) + .is_err() + { + return; + } + + // Acquiring the claim synchronises with the release below, so the threshold + // read here is the one stored by the previous collection. That collection + // may well have brought the pool back under it. + if mcrl2_aterm_pool_size() >= SIZE_UNTIL_GC.load(Ordering::Relaxed) { + self.collect(); + } + + GC_IN_PROGRESS.store(false, Ordering::Release); } /// Creates an ATerm from a string. @@ -227,7 +301,7 @@ impl ThreadTermPool { pub fn protect_container(&self, container: Arc) -> ProtectionIndex { let root = unsafe { self.container_protection_set.write_exclusive().protect(container) }; - trace!("Protected container index {}, protection set {}", root, self.index,); + debug_trace!("Protected container index {}, protection set {}", root, self.index,); root } @@ -238,7 +312,7 @@ impl ThreadTermPool { unsafe { let mut protection_set = self.protection_set.write_exclusive(); - trace!( + debug_trace!( "Dropped term {:?}, index {}, protection set {}", term.term, term.root, self.index ); @@ -252,7 +326,7 @@ impl ThreadTermPool { pub fn drop_container(&self, container_root: ProtectionIndex) { unsafe { let mut container_protection_set = self.container_protection_set.write_exclusive(); - trace!( + debug_trace!( "Dropped container index {}, protection set {}", container_root, self.index ); @@ -290,7 +364,7 @@ impl ThreadTermPool { // (`root`), so it stays live as long as the resulting `ATerm` holds that // root, which justifies the `'static` lifetime. let term = unsafe { ATermRef::new(term) }; - trace!( + debug_trace!( "Protected term {:?}, index {}, protection set {}", term, root, self.index ); @@ -304,13 +378,20 @@ impl ThreadTermPool { // `guard.unlock()` returns true only when this leaves the outermost // shared section, i.e. the thread is no longer busy. if guard.unlock() && counter == 0 { + self.gc_counter.set(GC_CHUNK.load(Ordering::Relaxed)); + // If garbage collection is necessary according to our requirements. - if mcrl2_aterm_pool_size() >= self.size_until_gc.get() { - self.collect(); + if mcrl2_aterm_pool_size() >= SIZE_UNTIL_GC.load(Ordering::Relaxed) { + self.collect_if_needed(); } - mcrl2_aterm_pool_resize(); - self.gc_counter.set(TEST_GC_INTERVAL); + // Only take the exclusive lock when a storage actually has to grow. + // Resizing unconditionally suspends every other thread (the exclusive + // lock waits for all of them to leave their shared sections) once per + // chunk per thread, which stalls exploration completely. + if mcrl2_aterm_pool_resize_is_needed() { + mcrl2_aterm_pool_resize(); + } } result @@ -331,14 +412,6 @@ impl Drop for ThreadTermPool { ); GLOBAL_TERM_POOL.lock().drop_thread_term_pool(self.index); - - // On macOS, thread-local destructors may run after the global aterm pool - // has been deallocated, so dropping the FFI callback would access freed - // memory. We intentionally leak the callback on macOS to avoid this. - #[cfg(not(target_os = "macos"))] - unsafe { - ManuallyDrop::drop(&mut self._callback); - } } } @@ -359,10 +432,11 @@ mod tests { use rand::SeedableRng; use rand::rngs::StdRng; + use super::super::random_term::random_term; + use super::THREAD_TERM_POOL; + use super::mcrl2_aterm_pool_size; use crate::ATerm; use crate::ATermRef; - use crate::THREAD_TERM_POOL; - use crate::random_term; /// Make sure that the term has the same number of arguments as its arity. fn verify_term(term: &ATermRef<'_>) { @@ -375,6 +449,36 @@ mod tests { } } + /// Garbage collection is disabled in the global term pool, which also makes + /// the mCRL2 pool ignore explicitly requested collections. Check that an + /// explicit collect actually removes the unprotected terms. + #[test] + fn test_collect_removes_unprotected_terms() { + let mut rng = rand::rng(); + + { + let _terms: Vec = (0..1000) + .map(|_| { + random_term( + &mut rng, + &[("f".to_string(), 2)], + &["a".to_string(), "b".to_string()], + 10, + ) + }) + .collect(); + } + + let before = mcrl2_aterm_pool_size(); + THREAD_TERM_POOL.with_borrow(|tp| tp.collect()); + let after = mcrl2_aterm_pool_size(); + + assert!( + after < before, + "collecting garbage did not remove any of the {before} terms in the pool" + ); + } + #[test] fn test_thread_aterm_pool_parallel() { let mut rng = rand::rng(); diff --git a/tools/mcrl2/crates/mcrl2/src/data_expression.rs b/tools/mcrl2/crates/mcrl2/src/data_expression.rs index 81a423aa8..ff19ed9b7 100644 --- a/tools/mcrl2/crates/mcrl2/src/data_expression.rs +++ b/tools/mcrl2/crates/mcrl2/src/data_expression.rs @@ -81,31 +81,31 @@ pub(crate) fn is_untyped_set_bag_comprehension_binder(term: &ATermRef<'_>) -> bo } /// Checks if this term is a data abstraction. -pub(crate) fn is_abstraction(term: &ATermRef<'_>) -> bool { +pub fn is_abstraction(term: &ATermRef<'_>) -> bool { term.require_valid(); mcrl2_data_expression_is_abstraction(term.get()) } /// Checks if this term is a data function symbol. -pub(crate) fn is_function_symbol(term: &ATermRef<'_>) -> bool { +pub fn is_function_symbol(term: &ATermRef<'_>) -> bool { term.require_valid(); mcrl2_data_expression_is_function_symbol(term.get()) } /// Checks if this term is a data where clause. -pub(crate) fn is_where_clause(term: &ATermRef<'_>) -> bool { +pub fn is_where_clause(term: &ATermRef<'_>) -> bool { term.require_valid(); mcrl2_data_expression_is_where_clause(term.get()) } /// Checks if this term is a data machine number. -pub(crate) fn is_machine_number(term: &ATermRef<'_>) -> bool { +pub fn is_machine_number(term: &ATermRef<'_>) -> bool { term.require_valid(); mcrl2_data_expression_is_machine_number(term.get()) } /// Checks if this term is a data untyped identifier. -pub(crate) fn is_untyped_identifier(term: &ATermRef<'_>) -> bool { +pub fn is_untyped_identifier(term: &ATermRef<'_>) -> bool { term.require_valid(); mcrl2_data_expression_is_untyped_identifier(term.get()) } @@ -140,11 +140,18 @@ mod inner { use super::is_abstraction; use super::is_application; + use super::is_bag_comprehension_binder; + use super::is_binding_operator; use super::is_data_expression; + use super::is_exists_binder; + use super::is_forall_binder; use super::is_function_symbol; + use super::is_lambda_binder; use super::is_machine_number; + use super::is_set_comprehension_binder; use super::is_sort_expression; use super::is_untyped_identifier; + use super::is_untyped_set_bag_comprehension_binder; use super::is_variable; use super::is_where_clause; use super::mcrl2_data_expression_to_string; @@ -312,6 +319,38 @@ mod inner { } } + /// Represents a binder operator constant from the mCRL2 toolset (e.g. `Lambda`, `Forall`, `Exists`). + #[mcrl2_term(is_binding_operator)] + pub struct DataBinder { + term: ATerm, + } + + impl DataBinder { + pub fn is_lambda(&self) -> bool { + is_lambda_binder(&self.term.copy()) + } + + pub fn is_forall(&self) -> bool { + is_forall_binder(&self.term.copy()) + } + + pub fn is_exists(&self) -> bool { + is_exists_binder(&self.term.copy()) + } + + pub fn is_set_comprehension(&self) -> bool { + is_set_comprehension_binder(&self.term.copy()) + } + + pub fn is_bag_comprehension(&self) -> bool { + is_bag_comprehension_binder(&self.term.copy()) + } + + pub fn is_untyped_set_bag_comprehension(&self) -> bool { + is_untyped_set_bag_comprehension_binder(&self.term.copy()) + } + } + /// Represents a data::abstraction from the mCRL2 toolset. #[mcrl2_term(is_abstraction)] pub struct DataAbstraction { @@ -319,8 +358,8 @@ mod inner { } impl DataAbstraction { - /// Returns the binding operator of the abstraction, i.e., lambda, forall, or exists. - pub fn binding_operator(&self) -> DataFunctionSymbolRef<'_> { + /// Returns the binder operator of the abstraction (e.g. `Lambda`, `Forall`, `Exists`). + pub fn binding_operator(&self) -> DataBinderRef<'_> { // SAFETY: `arg(0)` is a direct subterm of `self.term`, so it is a // parent term. unsafe { self.term.arg(0).upgrade(&self.term) }.into() @@ -439,7 +478,26 @@ mod inner { } } -pub use inner::*; +pub use inner::DataAbstraction; +pub use inner::DataAbstractionRef; +pub use inner::DataApplication; +pub use inner::DataApplicationRef; +pub use inner::DataBinder; +pub use inner::DataBinderRef; +pub use inner::DataExpression; +pub use inner::DataExpressionRef; +pub use inner::DataFunctionSymbol; +pub use inner::DataFunctionSymbolRef; +pub use inner::DataMachineNumber; +pub use inner::DataMachineNumberRef; +pub use inner::DataUntypedIdentifier; +pub use inner::DataUntypedIdentifierRef; +pub use inner::DataVariable; +pub use inner::DataVariableRef; +pub use inner::DataWhereClause; +pub use inner::DataWhereClauseRef; +pub use inner::SortExpression; +pub use inner::SortExpressionRef; // A `DataExpressionRef` is just an address into the maximally shared term pool, // so it can be freely copied. Liveness is the responsibility of whoever keeps @@ -469,23 +527,32 @@ impl DataExpressionRef<'static> { } } +/// Converts a substitution into the pairs of raw term addresses that the FFI +/// expects. +/// +/// The pairs only borrow the terms, so `sigma` must be kept alive for the +/// duration of the call that consumes the result. +pub(crate) fn to_assignment_pairs(sigma: &[(DataExpression, DataExpression)]) -> Vec { + sigma + .iter() + .map(|(lhs, rhs)| assignment_pair { + lhs: lhs.address(), + rhs: rhs.address(), + }) + .collect() +} + /// Substitutes variables in a data expression according to the given substitution sigma. pub fn substitute_variables( data_expression: &DataExpressionRef, sigma: Vec<(DataExpression, DataExpression)>, ) -> DataExpression { // Do not into_iter here, as we need to keep sigma alive for the call. - let sigma: Vec = sigma - .iter() - .map(|(lhs, rhs)| assignment_pair { - lhs: lhs.address(), - rhs: rhs.address(), - }) - .collect(); + let pairs = to_assignment_pairs(&sigma); DataExpression::new(ATerm::from_unique_ptr(mcrl2_data_expression_replace_variables( data_expression.get(), - &sigma, + &pairs, ))) } diff --git a/tools/mcrl2/crates/mcrl2/src/lib.rs b/tools/mcrl2/crates/mcrl2/src/lib.rs index 53a9b8e32..1ac9f80ad 100644 --- a/tools/mcrl2/crates/mcrl2/src/lib.rs +++ b/tools/mcrl2/crates/mcrl2/src/lib.rs @@ -10,17 +10,6 @@ mod pbes; mod pbes_expression; mod visitor; -pub(crate) use atermpp::*; -pub(crate) use data::*; -pub(crate) use data_expression::*; -pub(crate) use global_lock::*; -pub(crate) use log::*; -pub(crate) use lps::*; -pub(crate) use pbes::*; -pub(crate) use pbes_expression::*; -pub(crate) use visitor::*; - -// Public API re-exports from atermpp pub use atermpp::ATerm; pub use atermpp::ATermArgs; pub use atermpp::ATermInt; @@ -33,18 +22,27 @@ pub use atermpp::ATermRef; pub use atermpp::ATermSend; pub use atermpp::ATermString; pub use atermpp::ATermStringRef; +pub(crate) use atermpp::BfTermPool; pub use atermpp::Markable; pub use atermpp::Protected; pub use atermpp::Symbol; pub use atermpp::SymbolRef; pub use atermpp::TermIterator; pub use atermpp::Todo; +pub(crate) use atermpp::is_aterm_int; +pub(crate) use atermpp::is_aterm_string; pub use atermpp::merc_aterm_to_mcrl2; + +pub use mcrl2_sys::atermpp::ffi::_aterm; + pub use data::DataSpecification; + pub use data_expression::DataAbstraction; pub use data_expression::DataAbstractionRef; pub use data_expression::DataApplication; pub use data_expression::DataApplicationRef; +pub use data_expression::DataBinder; +pub use data_expression::DataBinderRef; pub use data_expression::DataExpression; pub use data_expression::DataExpressionRef; pub use data_expression::DataFunctionSymbol; @@ -59,11 +57,21 @@ pub use data_expression::DataWhereClause; pub use data_expression::DataWhereClauseRef; pub use data_expression::SortExpression; pub use data_expression::SortExpressionRef; +pub use data_expression::is_abstraction; pub use data_expression::is_application; +pub(crate) use data_expression::is_data_expression; +pub use data_expression::is_function_symbol; +pub use data_expression::is_machine_number; +pub use data_expression::is_untyped_identifier; pub use data_expression::is_variable; +pub use data_expression::is_where_clause; pub use data_expression::substitute_variables; + +pub(crate) use global_lock::lock_global; + pub use log::set_reporting_level; pub use log::verbosity_to_log_level; + pub use lps::LearnSuccessorsContext; pub use lps::LinearProcessInitializer; pub use lps::LinearProcessSpecification; @@ -74,10 +82,13 @@ pub use lps::pretty_print_multi_action; pub use lps::read_lps; pub use lps::read_lps_text; pub use lps::tau_multi_action; -pub use mcrl2_sys::atermpp::ffi::_aterm; + pub use pbes::ControlFlowGraph; pub use pbes::ControlFlowGraphVertex; pub use pbes::Pbes; +pub use pbes::PbesEquation; +pub use pbes::PbesEquations; +pub use pbes::PbesRewriteContext; pub use pbes::PbesStategraph; pub use pbes::PredicateVariable; pub use pbes::PropositionalVariable; @@ -88,6 +99,7 @@ pub use pbes::StategraphEquation; pub use pbes::make_data_assignment_list; pub use pbes::reorder_propositional_variables; pub use pbes::substitute_data_expressions; + pub use pbes_expression::PbesAnd; pub use pbes_expression::PbesAndRef; pub use pbes_expression::PbesExists; @@ -104,9 +116,36 @@ pub use pbes_expression::PbesOr; pub use pbes_expression::PbesOrRef; pub use pbes_expression::PbesPropositionalVariableInstantiation; pub use pbes_expression::PbesPropositionalVariableInstantiationRef; +pub use pbes_expression::is_pbes_and; +pub use pbes_expression::is_pbes_exists; +pub(crate) use pbes_expression::is_pbes_expression; +pub use pbes_expression::is_pbes_false; +pub use pbes_expression::is_pbes_forall; +pub use pbes_expression::is_pbes_imp; +pub use pbes_expression::is_pbes_not; +pub use pbes_expression::is_pbes_or; pub use pbes_expression::is_pbes_propositional_variable_instantiation; +pub use pbes_expression::is_pbes_true; + +pub use visitor::ClosureVisitor; +pub use visitor::DataExpressionContextVisitor; pub use visitor::DataExpressionVisitor; +pub use visitor::Descend; +pub use visitor::PbesClosureVisitor; +pub use visitor::PbesConnective; +pub use visitor::PbesExpressionContextVisitor; pub use visitor::PbesExpressionVisitor; +pub use visitor::PbesFlattenIter; +pub use visitor::PbesFlattenStack; +pub use visitor::VisitResult; +pub use visitor::flatten_associative; +pub use visitor::flatten_pbes_and_into; +pub use visitor::flatten_pbes_or_into; pub use visitor::free_variables_data_expression; pub use visitor::pbes_expression_pvi; +pub use visitor::try_visit_data_expr_with; +pub use visitor::try_visit_pbes_expr_with; pub use visitor::variable_occurrences_data_expression; +pub use visitor::variable_occurrences_pbes_expression; +pub use visitor::visit_data_expr_with; +pub use visitor::visit_pbes_expr_with; diff --git a/tools/mcrl2/crates/mcrl2/src/lps.rs b/tools/mcrl2/crates/mcrl2/src/lps.rs index ba90dd956..360c4f776 100644 --- a/tools/mcrl2/crates/mcrl2/src/lps.rs +++ b/tools/mcrl2/crates/mcrl2/src/lps.rs @@ -354,11 +354,13 @@ impl LearnSuccessorsContext { ); let mut context = self.context.borrow_mut(); + // The only error the FFI reports is the length mismatch asserted above. mcrl2_lps_set_assignments( context.as_mut().expect("The context is always defined"), variables, values, - ); + ) + .expect("variables and values have equal length"); } /// Rewrites `expr` under the context's current substitution (sigma) and diff --git a/tools/mcrl2/crates/mcrl2/src/pbes.rs b/tools/mcrl2/crates/mcrl2/src/pbes.rs index 16f527ec5..043fc64e0 100644 --- a/tools/mcrl2/crates/mcrl2/src/pbes.rs +++ b/tools/mcrl2/crates/mcrl2/src/pbes.rs @@ -1,9 +1,11 @@ +use std::cell::RefCell; use std::fmt; +use std::marker::PhantomData; use std::rc::Rc; use mcrl2_sys::cxx::CxxVector; use mcrl2_sys::cxx::UniquePtr; -use mcrl2_sys::pbes::ffi::assignment_pair; +use mcrl2_sys::data::ffi::mcrl2_pbes_expression_replace_variables; use mcrl2_sys::pbes::ffi::local_control_flow_graph_vertex; use mcrl2_sys::pbes::ffi::mcrl2_load_pbes_from_pbes_file; use mcrl2_sys::pbes::ffi::mcrl2_load_pbes_from_text; @@ -15,13 +17,25 @@ use mcrl2_sys::pbes::ffi::mcrl2_local_control_flow_graph_vertex_outgoing_edges; use mcrl2_sys::pbes::ffi::mcrl2_local_control_flow_graph_vertex_value; use mcrl2_sys::pbes::ffi::mcrl2_local_control_flow_graph_vertices; use mcrl2_sys::pbes::ffi::mcrl2_make_data_assignment_list; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_clone; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_create_rewrite_context; use mcrl2_sys::pbes::ffi::mcrl2_pbes_data_specification; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_equation_formula; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_equation_is_mu; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_equation_variable; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_equations; use mcrl2_sys::pbes::ffi::mcrl2_pbes_expression_replace_propositional_variables; -use mcrl2_sys::pbes::ffi::mcrl2_pbes_expression_replace_variables; use mcrl2_sys::pbes::ffi::mcrl2_pbes_initial_state; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_instantiate_global_variables; use mcrl2_sys::pbes::ffi::mcrl2_pbes_is_propositional_variable; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_one_point_rule; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_order_quantified_variables; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_rewrite_formula; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_rewrite_set_assignments; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_simplify_quantifiers; use mcrl2_sys::pbes::ffi::mcrl2_pbes_to_srf_pbes; use mcrl2_sys::pbes::ffi::mcrl2_pbes_to_string; +use mcrl2_sys::pbes::ffi::mcrl2_pbes_unify_parameters; use mcrl2_sys::pbes::ffi::mcrl2_srf_equation_is_conjunctive; use mcrl2_sys::pbes::ffi::mcrl2_srf_equation_is_mu; use mcrl2_sys::pbes::ffi::mcrl2_srf_equations_summands; @@ -38,6 +52,7 @@ use mcrl2_sys::pbes::ffi::mcrl2_stategraph_local_algorithm_equation; use mcrl2_sys::pbes::ffi::mcrl2_stategraph_local_algorithm_equations; use mcrl2_sys::pbes::ffi::mcrl2_stategraph_local_algorithm_run; use mcrl2_sys::pbes::ffi::pbes; +use mcrl2_sys::pbes::ffi::pbes_equation; use mcrl2_sys::pbes::ffi::predicate_variable; use mcrl2_sys::pbes::ffi::srf_equation; use mcrl2_sys::pbes::ffi::srf_pbes; @@ -45,6 +60,7 @@ use mcrl2_sys::pbes::ffi::srf_summand; use mcrl2_sys::pbes::ffi::stategraph_algorithm; use mcrl2_sys::pbes::ffi::stategraph_equation; use merc_utilities::MercError; +use merc_utilities::Timing; use crate::ATerm; use crate::ATermList; @@ -54,6 +70,7 @@ use crate::DataSpecification; use crate::DataVariable; use crate::PbesExpression; use crate::PbesPropositionalVariableInstantiation; +use crate::data_expression::to_assignment_pairs; use crate::lock_global; /// mcrl2::pbes_system::pbes @@ -115,6 +132,98 @@ impl Pbes { )) }) } + + /// Unifies the parameter vectors of all equations in-place. + /// + /// After this call every equation shares the same parameter vector (same names and sorts). + /// Unlike `SrfPbes::unify_parameters`, this operates directly on the PBES without + /// converting to standard recursive form, so formula structure is preserved. + pub fn unify_parameters(&mut self, ignore_ce_equations: bool, reset: bool) -> Result<(), MercError> { + mcrl2_pbes_unify_parameters(self.pbes.pin_mut(), ignore_ce_equations, reset)?; + Ok(()) + } + + /// Substitutes a value for every global variable of the PBES, in-place. + /// + /// Returns an error when a global variable cannot be instantiated. + pub fn instantiate_global_variables(&mut self) -> Result<(), MercError> { + mcrl2_pbes_instantiate_global_variables(self.pbes.pin_mut())?; + Ok(()) + } + + /// Simplifies every equation body in-place, evaluating data subterms and + /// eliminating quantifiers that range over nothing. + pub fn simplify_quantifiers(&mut self) -> Result<(), MercError> { + mcrl2_pbes_simplify_quantifiers(self.pbes.pin_mut())?; + Ok(()) + } + + /// Applies the one point rule to every equation body in-place, replacing a + /// quantifier that pins its variable to a single value by that instance. + pub fn one_point_rule(&mut self) -> Result<(), MercError> { + mcrl2_pbes_one_point_rule(self.pbes.pin_mut())?; + Ok(()) + } + + /// Orders the quantified variables of every equation body in-place, so that + /// quantifiers differing only in the order of their variables become the + /// same term. + pub fn order_quantified_variables(&mut self) -> Result<(), MercError> { + mcrl2_pbes_order_quantified_variables(self.pbes.pin_mut())?; + Ok(()) + } + + /// Applies the preprocessing that mCRL2's `pbesinst_lazy_algorithm` performs + /// before instantiating a PBES, in-place, reporting every step it runs. + /// + /// This is [`Pbes::instantiate_global_variables`], [`Pbes::simplify_quantifiers`], + /// [`Pbes::one_point_rule`] and [`Pbes::order_quantified_variables`] in that + /// order, which is what makes an exploration comparable to `pbessolve`: that + /// tool never instantiates a PBES without it. + /// + /// Only the equation bodies change, so the parameter vector that + /// [`Pbes::unify_parameters`] produces is unaffected and the symmetry + /// generators keep indexing into the same vector. + /// + /// Every step is registered on `timing`, so a tool that prints its timings + /// reports them alongside the rest of its phases. + /// + /// Returns an error when a global variable cannot be instantiated. + pub fn preprocess(&mut self, timing: &Timing) -> Result<(), MercError> { + // Named steps rather than four inlined calls so that the reporting + // cannot drift out of sync with what is actually run. + let steps: [(&str, fn(&mut Pbes) -> Result<(), MercError>); 4] = [ + ("instantiate global variables", Pbes::instantiate_global_variables), + ("simplify quantifiers", Pbes::simplify_quantifiers), + ("one point rule", Pbes::one_point_rule), + ("order quantified variables", Pbes::order_quantified_variables), + ]; + + for (name, step) in steps { + timing.measure(&format!("preprocess: {name}"), || step(self))?; + } + + Ok(()) + } + + /// Returns the equations of the PBES, in declaration order. + /// + /// Unlike [`SrfPbes::equations`], this returns the equations exactly as + /// they appear in the PBES: no conversion to standard recursive form, and + /// equations are not required to share a single unified parameter vector. + pub fn equations(&self) -> PbesEquations { + let mut ffi_equations = CxxVector::new(); + mcrl2_pbes_equations( + ffi_equations.pin_mut(), + self.pbes.as_ref().expect("pbes UniquePtr should not be null"), + ); + + let equations = ffi_equations.iter().map(|eq| PbesEquation::new(eq)).collect(); + PbesEquations { + equations, + _ffi_equations: ffi_equations, + } + } } /// Build a `data::assignment_list` from two parallel ATerm lists: a `variable_list` and a @@ -130,6 +239,143 @@ impl fmt::Display for Pbes { } } +impl Clone for Pbes { + fn clone(&self) -> Self { + let _guard = lock_global(); + Pbes { + pbes: mcrl2_pbes_clone(&self.pbes), + } + } +} + +/// Wraps an `enumerate_quantifiers_rewriter` together with a substitution σ. +/// +/// Not `Send`: the underlying C++ rewriter is single-threaded. Clone the PBES +/// and construct a separate context per thread. +pub struct PbesRewriteContext { + ctx: RefCell>, + _not_send: PhantomData<*const ()>, +} + +impl PbesRewriteContext { + pub fn from_data_spec(data_spec: &DataSpecification) -> Result { + let ctx = mcrl2_pbes_create_rewrite_context( + data_spec + .get() + .as_ref() + .expect("data_specification UniquePtr should not be null"), + )?; + Ok(PbesRewriteContext { + ctx: RefCell::new(ctx), + _not_send: PhantomData, + }) + } + + /// Sets σ := { `variables[i]` ↦ `values[i]` } for the next rewrite call. + /// + /// # Safety + /// Every pointer in `variables` must be a live `data::variable` term, and + /// every pointer in `values` must be a live `data::data_expression` term. + pub unsafe fn set_assignments( + &self, + variables: &[*const mcrl2_sys::atermpp::ffi::_aterm], + values: &[*const mcrl2_sys::atermpp::ffi::_aterm], + ) { + assert_eq!( + variables.len(), + values.len(), + "Variables and values must have equal length" + ); + + // The only error the FFI reports is the length mismatch asserted above. + mcrl2_pbes_rewrite_set_assignments(self.ctx.borrow_mut().pin_mut(), variables, values) + .expect("variables and values have equal length"); + } + + /// Rewrites `formula` under the current σ. + /// + /// The result is a protected `PbesExpression` and remains valid + /// independently of subsequent calls. + /// + /// Returns an error when the rewriter cannot evaluate the formula, which + /// happens for quantifiers that it fails to enumerate (for instance over a + /// function sort, or over an infinite sort). + /// + /// # Safety + /// `formula` must be a live `pbes_expression` term. + pub unsafe fn rewrite_formula(&self, formula: &PbesExpression) -> Result { + let ptr = unsafe { mcrl2_pbes_rewrite_formula(self.ctx.borrow_mut().pin_mut(), formula.get()) }?; + Ok(PbesExpression::new(unsafe { ATerm::from_ptr(ptr) })) + } +} + +/// The equations of a [`Pbes`], in declaration order. +/// +/// Owns the underlying FFI equation vector, so the [`PbesEquation`]s (each a +/// pointer into it) stay valid for as long as this value is alive. +pub struct PbesEquations { + equations: Vec, + _ffi_equations: UniquePtr>, +} + +impl std::ops::Deref for PbesEquations { + type Target = [PbesEquation]; + + fn deref(&self) -> &[PbesEquation] { + &self.equations + } +} + +/// mcrl2::pbes_system::pbes_equation +pub struct PbesEquation { + equation: *const pbes_equation, +} + +impl PbesEquation { + /// Creates a new [`PbesEquation`] from the given FFI equation pointer. + pub(crate) fn new(equation: *const pbes_equation) -> Self { + PbesEquation { equation } + } + + /// Returns a reference to the underlying FFI equation. + fn as_ref(&self) -> &pbes_equation { + unsafe { self.equation.as_ref().expect("Pointer should be valid") } + } + + /// Returns true when the equation has a least fixed-point (μ) symbol, false for greatest (ν). + pub fn is_mu(&self) -> bool { + mcrl2_pbes_equation_is_mu(self.as_ref()) + } + + /// Returns the bound predicate variable (name and parameters) of the equation. + pub fn variable(&self) -> PropositionalVariable { + // SAFETY: `self.equation` is a live equation pointer (kept alive by the + // owning `PbesEquations`), and the FFI returns the live variable term + // wrapped immediately by `from_ptr`. + PropositionalVariable::new(unsafe { ATerm::from_ptr(mcrl2_pbes_equation_variable(self.as_ref())) }) + } + + /// Returns the right-hand side predicate formula of the equation. + pub fn formula(&self) -> PbesExpression { + // SAFETY: `self.equation` is a live equation pointer (kept alive by the + // owning `PbesEquations`), and the FFI returns the live formula term + // wrapped immediately by `from_ptr`. + PbesExpression::new(unsafe { ATerm::from_ptr(mcrl2_pbes_equation_formula(self.as_ref())) }) + } +} + +impl fmt::Debug for PbesEquation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {:?} = {}", + if self.is_mu() { "mu" } else { "nu" }, + self.variable(), + self.formula() + ) + } +} + /// mcrl2::pbes_system::stategraph_algorithm pub struct PbesStategraph { control_flow_graphs: Vec, @@ -190,7 +436,8 @@ impl ControlFlowGraph { } pub(crate) fn new(algorithm: Rc>, index: usize) -> Self { - let cfg = mcrl2_stategraph_local_algorithm_cfg(&algorithm, index); + // Only ever called with an index from `0..mcrl2_stategraph_local_algorithm_cfgs`. + let cfg = mcrl2_stategraph_local_algorithm_cfg(&algorithm, index).expect("cfg index is in range"); let vertices = (0..mcrl2_local_control_flow_graph_vertices(cfg)) .map(|vertex_index| ControlFlowGraphVertex::new(algorithm.clone(), index, vertex_index)) .collect::>(); @@ -239,8 +486,9 @@ impl ControlFlowGraphVertex { /// Construct a new vertex and retrieve its edges as well. /// TODO: This should probably be private. pub(crate) fn new(algorithm: Rc>, cfg: usize, vertex: usize) -> Self { - let cfg = mcrl2_stategraph_local_algorithm_cfg(&algorithm, cfg); - let vertex = mcrl2_local_control_flow_graph_vertex(cfg, vertex); + let cfg = mcrl2_stategraph_local_algorithm_cfg(&algorithm, cfg).expect("cfg index is in range"); + // Only ever called with an index from `0..mcrl2_local_control_flow_graph_vertices`. + let vertex = mcrl2_local_control_flow_graph_vertex(cfg, vertex).expect("vertex index is in range"); let outgoing_edges_ffi = mcrl2_local_control_flow_graph_vertex_outgoing_edges(vertex); let outgoing_edges = outgoing_edges_ffi @@ -361,7 +609,9 @@ impl StategraphEquation { } pub(crate) fn new(algorithm: Rc>, index: usize) -> Self { - let equation = mcrl2_stategraph_local_algorithm_equation(&algorithm, index); + // Only ever called with an index from `0..mcrl2_stategraph_local_algorithm_equations`. + let equation = + mcrl2_stategraph_local_algorithm_equation(&algorithm, index).expect("equation index is in range"); let predicate_variables = mcrl2_stategraph_equation_predicate_variables(equation); let predicate_variables = predicate_variables.iter().map(|v| PredicateVariable::new(v)).collect(); @@ -374,7 +624,8 @@ impl StategraphEquation { /// Returns a reference to the underlying FFI equation. fn as_ref(&self) -> &stategraph_equation { - mcrl2_stategraph_local_algorithm_equation(&self.algorithm, self.index) + // `self.index` was validated when this equation was constructed. + mcrl2_stategraph_local_algorithm_equation(&self.algorithm, self.index).expect("equation index is in range") } } @@ -422,7 +673,7 @@ impl SrfPbes { /// After unification the C++ `srf_pbes` object is updated in-place, so the /// Rust-side equation snapshot must be refreshed to stay consistent. pub fn unify_parameters(&mut self, ignore_ce_equations: bool, reset: bool) -> Result<(), MercError> { - mcrl2_srf_pbes_unify_parameters(self.srf_pbes.pin_mut(), ignore_ce_equations, reset); + mcrl2_srf_pbes_unify_parameters(self.srf_pbes.pin_mut(), ignore_ce_equations, reset)?; // Refresh the equations snapshot from the now-unified C++ object. let mut ffi_equations = CxxVector::new(); @@ -592,25 +843,23 @@ pub fn substitute_data_expressions( sigma: Vec<(DataExpression, DataExpression)>, ) -> PbesExpression { // Do not into_iter here, as we need to keep sigma alive for the call. - let sigma: Vec = sigma - .iter() - .map(|(lhs, rhs)| assignment_pair { - lhs: lhs.address(), - rhs: rhs.address(), - }) - .collect(); + let pairs = to_assignment_pairs(&sigma); PbesExpression::new(ATerm::from_unique_ptr(mcrl2_pbes_expression_replace_variables( expr.term.get(), - &sigma, + &pairs, ))) } -/// Replaces propositional variables in the given PBES expression according to the given substitution sigma. +/// Reorders the parameters of every propositional variable instantiation in +/// the given PBES expression according to the permutation `pi`. +/// +/// Returns an error when `pi` is not a permutation of `0..pi.len()`, or when an +/// instantiation with a different number of parameters is encountered. // The `&Vec` is required by the `mcrl2-sys` FFI binding. #[allow(clippy::ptr_arg)] -pub fn reorder_propositional_variables(expr: &PbesExpression, pi: &Vec) -> PbesExpression { - PbesExpression::new(ATerm::from_unique_ptr( - mcrl2_pbes_expression_replace_propositional_variables(expr.term.get(), pi), - )) +pub fn reorder_propositional_variables(expr: &PbesExpression, pi: &Vec) -> Result { + Ok(PbesExpression::new(ATerm::from_unique_ptr( + mcrl2_pbes_expression_replace_propositional_variables(expr.term.get(), pi)?, + ))) } diff --git a/tools/mcrl2/crates/mcrl2/src/pbes_expression.rs b/tools/mcrl2/crates/mcrl2/src/pbes_expression.rs index 92b62d528..037425841 100644 --- a/tools/mcrl2/crates/mcrl2/src/pbes_expression.rs +++ b/tools/mcrl2/crates/mcrl2/src/pbes_expression.rs @@ -16,30 +16,38 @@ pub fn is_pbes_propositional_variable_instantiation(term: &ATermRef<'_>) -> bool mcrl2_pbes_is_propositional_variable_instantiation(term.get()) } -pub(crate) fn is_pbes_not(term: &ATermRef<'_>) -> bool { +pub fn is_pbes_not(term: &ATermRef<'_>) -> bool { mcrl2_pbes_is_not(term.get()) } -pub(crate) fn is_pbes_and(term: &ATermRef<'_>) -> bool { +pub fn is_pbes_and(term: &ATermRef<'_>) -> bool { mcrl2_sys::pbes::ffi::mcrl2_pbes_is_and(term.get()) } -pub(crate) fn is_pbes_or(term: &ATermRef<'_>) -> bool { +pub fn is_pbes_or(term: &ATermRef<'_>) -> bool { mcrl2_sys::pbes::ffi::mcrl2_pbes_is_or(term.get()) } -pub(crate) fn is_pbes_imp(term: &ATermRef<'_>) -> bool { +pub fn is_pbes_imp(term: &ATermRef<'_>) -> bool { mcrl2_sys::pbes::ffi::mcrl2_pbes_is_imp(term.get()) } -pub(crate) fn is_pbes_forall(term: &ATermRef<'_>) -> bool { +pub fn is_pbes_forall(term: &ATermRef<'_>) -> bool { mcrl2_sys::pbes::ffi::mcrl2_pbes_is_forall(term.get()) } -pub(crate) fn is_pbes_exists(term: &ATermRef<'_>) -> bool { +pub fn is_pbes_exists(term: &ATermRef<'_>) -> bool { mcrl2_sys::pbes::ffi::mcrl2_pbes_is_exists(term.get()) } +pub fn is_pbes_true(term: &ATermRef<'_>) -> bool { + mcrl2_sys::pbes::ffi::mcrl2_pbes_is_true(term.get()) +} + +pub fn is_pbes_false(term: &ATermRef<'_>) -> bool { + mcrl2_sys::pbes::ffi::mcrl2_pbes_is_false(term.get()) +} + // This module is only used internally to run the proc macro. #[mcrl2_derive_terms] mod inner { @@ -54,6 +62,7 @@ mod inner { use crate::ATermRef; use crate::ATermStringRef; use crate::DataExpression; + use crate::DataVariable; use crate::Markable; use crate::Todo; use crate::is_pbes_and; @@ -169,6 +178,11 @@ mod inner { } impl PbesForall { + /// Returns the bound variables of the forall expression. + pub fn variables(&self) -> ATermListRef<'_, DataVariable> { + self.arg(0).into() + } + /// Returns the body of the forall expression. pub fn body(&self) -> PbesExpressionRef<'_> { self.arg(1).into() @@ -182,6 +196,11 @@ mod inner { } impl PbesExists { + /// Returns the bound variables of the exists expression. + pub fn variables(&self) -> ATermListRef<'_, DataVariable> { + self.arg(0).into() + } + /// Returns the body of the exists expression. pub fn body(&self) -> PbesExpressionRef<'_> { self.arg(1).into() @@ -189,7 +208,22 @@ mod inner { } } -pub use inner::*; +pub use inner::PbesAnd; +pub use inner::PbesAndRef; +pub use inner::PbesExists; +pub use inner::PbesExistsRef; +pub use inner::PbesExpression; +pub use inner::PbesExpressionRef; +pub use inner::PbesForall; +pub use inner::PbesForallRef; +pub use inner::PbesImp; +pub use inner::PbesImpRef; +pub use inner::PbesNot; +pub use inner::PbesNotRef; +pub use inner::PbesOr; +pub use inner::PbesOrRef; +pub use inner::PbesPropositionalVariableInstantiation; +pub use inner::PbesPropositionalVariableInstantiationRef; impl From for PbesExpression { fn from(inst: PbesPropositionalVariableInstantiation) -> Self { @@ -371,6 +405,16 @@ impl<'a> From> for DataExpressionRef<'a> { } } +// The underlying raw pointer is maximally shared and never modified; copying +// the reference is always safe. Liveness is the caller's responsibility. +impl Clone for PbesExpressionRef<'_> { + fn clone(&self) -> Self { + *self + } +} + +impl Copy for PbesExpressionRef<'_> {} + impl From for PbesExpression { fn from(expr: DataExpression) -> Self { Self::new(expr.into()) @@ -382,3 +426,15 @@ impl<'a> From> for PbesExpressionRef<'a> { Self::new(expr.into()) } } + +impl PbesExpressionRef<'static> { + /// Creates an unprotected `'static` reference to the term at `ptr`. + /// + /// # Safety + /// `ptr` must point to a live PBES expression term that will remain alive + /// for the duration of use — typically because it is held by a + /// garbage-collection container such as [`crate::Protected`]. + pub unsafe fn from_address(ptr: *const crate::_aterm) -> Self { + Self::new(unsafe { ATermRef::new(ptr) }) + } +} diff --git a/tools/mcrl2/crates/mcrl2/src/visitor.rs b/tools/mcrl2/crates/mcrl2/src/visitor.rs index 37834f05b..5ba050e54 100644 --- a/tools/mcrl2/crates/mcrl2/src/visitor.rs +++ b/tools/mcrl2/crates/mcrl2/src/visitor.rs @@ -1,3 +1,9 @@ +use std::marker::PhantomData; +use std::ops::ControlFlow; + +use mcrl2_sys::atermpp::ffi::_aterm; + +use crate::ATermRef; use crate::DataAbstractionRef; use crate::DataApplicationRef; use crate::DataExpression; @@ -216,6 +222,43 @@ pub fn variable_occurrences_data_expression(expr: &DataExpressionRef<'_>) -> Vec result } +/// Returns all the variables occurring in the given PBES expression. +/// +/// This covers variables in propositional variable instantiation arguments and +/// in any data sub-expressions. Occurrences are listed as many times as they +/// appear; deduplicate at the call site if a set is required. +pub fn variable_occurrences_pbes_expression(expr: &PbesExpressionRef<'_>) -> Vec { + let mut result = Vec::new(); + + struct PbesVarCollector<'a> { + result: &'a mut Vec, + } + + impl PbesExpressionVisitor for PbesVarCollector<'_> { + fn visit_propositional_variable_instantiation( + &mut self, + inst: &PbesPropositionalVariableInstantiationRef<'_>, + ) -> Option { + for arg in inst.arguments().iter() { + for v in variable_occurrences_data_expression(&arg.copy()) { + self.result.push(v); + } + } + None + } + + fn visit_data_expression(&mut self, expr: &DataExpressionRef<'_>) -> Option { + for v in variable_occurrences_data_expression(expr) { + self.result.push(v); + } + None + } + } + + PbesVarCollector { result: &mut result }.visit(expr); + result +} + /// Returns all the free variables of the given data expression. /// /// This returns occurrences, so a free variable that appears multiple times is @@ -271,3 +314,621 @@ pub fn free_variables_data_expression(expr: &DataExpressionRef<'_>) -> Vec) -> bool { + match self { + PbesConnective::And => is_pbes_and(term), + PbesConnective::Or => is_pbes_or(term), + } + } +} + +/// A reusable worklist for [`PbesFlattenIter`]. +/// +/// Holds bare term addresses so it carries no lifetime and can live in a +/// long-lived per-thread context; each traversal re-attaches the lifetime of the +/// chain it walks. +#[derive(Default)] +pub struct PbesFlattenStack { + stack: Vec<*const _aterm>, +} + +// SAFETY: the addresses are only pushed and popped, never dereferenced by the +// stack itself; the iterator hands each one back as a reference bound to the +// lifetime of the parent term that keeps it live. +unsafe impl Send for PbesFlattenStack {} + +impl PbesFlattenStack { + pub fn new() -> Self { + PbesFlattenStack::default() + } +} + +/// Yields every leaf of a nested `&&` (or `||`) chain, left to right. +/// +/// Allocates nothing and, in particular, protects nothing: every leaf is a +/// subterm of the term it was built from, which the caller already holds live. +pub struct PbesFlattenIter<'a, 'b> { + /// Terms still to be examined, deepest-rightmost last. + stack: &'b mut Vec<*const _aterm>, + + /// The connective being flattened; anything else is a leaf. + connective: PbesConnective, + + /// Ties each yielded leaf to the chain it is a subterm of. + parent: PhantomData>, +} + +impl PbesFlattenIter<'_, '_> { + /// Starts a traversal of the `connective` chain rooted at `expr`. A term that + /// is not an application of `connective` is a chain of one. + /// + /// `stack` is cleared on entry and reused, and is what keeps the walk + /// iterative: quantifier enumeration produces one operand per enumerated + /// value, far deeper than the call stack can safely recurse over. + pub fn new<'a, 'b>( + expr: PbesExpressionRef<'a>, + connective: PbesConnective, + stack: &'b mut PbesFlattenStack, + ) -> PbesFlattenIter<'a, 'b> { + stack.stack.clear(); + stack.stack.push(expr.address()); + + PbesFlattenIter { + stack: &mut stack.stack, + connective, + parent: PhantomData, + } + } +} + +impl<'a> Iterator for PbesFlattenIter<'a, '_> { + type Item = PbesExpressionRef<'a>; + + fn next(&mut self) -> Option> { + while let Some(address) = self.stack.pop() { + // SAFETY: every address on the stack is the chain root or a subterm of + // it, and the root is live for 'a, so the term is live for 'a too. + let term = unsafe { ATermRef::new(address) }; + + if self.connective.matches(&term) { + // Right operand first, so the left one is popped first and the + // leaves come out in the order they are written. + self.stack.push(term.arg(1).address()); + self.stack.push(term.arg(0).address()); + } else { + return Some(term.into()); + } + } + + None + } +} + +/// Appends every non-`&&` leaf of a nested AND chain to `out`, reusing the buffer. +pub fn flatten_pbes_and_into(expr: PbesExpressionRef<'_>, out: &mut Vec) { + let mut stack = PbesFlattenStack::new(); + out.extend(PbesFlattenIter::new(expr, PbesConnective::And, &mut stack).map(|leaf| leaf.protect())); +} + +/// Appends every non-`||` leaf of a nested OR chain to `out`, reusing the buffer. +pub fn flatten_pbes_or_into(expr: PbesExpressionRef<'_>, out: &mut Vec) { + let mut stack = PbesFlattenStack::new(); + out.extend(PbesFlattenIter::new(expr, PbesConnective::Or, &mut stack).map(|leaf| leaf.protect())); +} + +/// Recursively collects the arguments of a nested application chain where `is_op` matches, +/// flattening any depth of nesting. Intermediate matching applications are not included. +pub fn flatten_associative(expr: &DataExpressionRef<'_>, mut is_op: F) -> Vec +where + F: FnMut(&DataExpressionRef<'_>) -> bool, +{ + let mut result = Vec::new(); + collect_flat(expr, &mut is_op, &mut result); + result +} + +fn collect_flat(expr: &DataExpressionRef<'_>, is_op: &mut F, out: &mut Vec) +where + F: FnMut(&DataExpressionRef<'_>) -> bool, +{ + if is_op(expr) { + for arg in DataApplicationRef::from(expr.copy()).data_arguments() { + let owned = DataExpression::new(arg.protect()); + collect_flat(&owned.copy(), is_op, out); + } + } else { + out.push(expr.copy().protect()); + } +} + +/// Controls how [`DataExpressionContextVisitor::try_visit`] and +/// [`PbesExpressionContextVisitor::try_visit`] proceed below the current node. +pub enum Descend { + /// Visit children with this context. + Descend(C), + /// Do not visit children. + Prune, +} + +/// Outcome of visiting a single node: either break out of the traversal with a value of type +/// `T`, or continue with a [`Descend`] decision carrying context `C`. +pub type VisitResult = Result>, E>; + +/// Context-threading traversal over data expressions. Override the `visit_*` methods, for example +/// [`visit_application`], to inspect each node; the provided [`try_visit`] drives recursion into +/// children automatically. +/// +/// Use [`ClosureVisitor`] or call [`try_visit_data_expr_with`] to drive traversal from a closure. +/// +/// [`visit_application`]: DataExpressionContextVisitor::visit_application +/// [`try_visit`]: DataExpressionContextVisitor::try_visit +pub trait DataExpressionContextVisitor { + type Context: Copy; + type Break; + type Error; + + fn visit_variable( + &mut self, + _var: &DataVariableRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_application( + &mut self, + _appl: &DataApplicationRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_abstraction( + &mut self, + _abstraction: &DataAbstractionRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_function_symbol( + &mut self, + _function_symbol: &DataFunctionSymbolRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_where_clause( + &mut self, + _where_: &DataWhereClauseRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_machine_number( + &mut self, + _number: &DataMachineNumberRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_untyped_identifier( + &mut self, + _identifier: &DataUntypedIdentifierRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn try_visit( + &mut self, + expr: &DataExpressionRef<'_>, + ctx: Self::Context, + ) -> Result, Self::Error> { + if is_variable(&expr.copy()) { + let var = DataVariableRef::from(expr.copy()); + match self.visit_variable(&var, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(_) => {} // leaf + } + } else if is_application(&expr.copy()) { + let appl = DataApplicationRef::from(expr.copy()); + let ctx = match self.visit_application(&appl, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + for arg in appl.data_arguments() { + let arg_ref: DataExpressionRef<'_> = arg.into(); + if let Some(result) = self.try_visit(&arg_ref, ctx)? { + return Ok(Some(result)); + } + } + } else if is_abstraction(&expr.copy()) { + let abstraction = DataAbstractionRef::from(expr.copy()); + let ctx = match self.visit_abstraction(&abstraction, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + if let Some(result) = self.try_visit(&abstraction.body(), ctx)? { + return Ok(Some(result)); + } + } else if is_function_symbol(&expr.copy()) { + let fs = DataFunctionSymbolRef::from(expr.copy()); + match self.visit_function_symbol(&fs, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(_) => {} // leaf + } + } else if is_where_clause(&expr.copy()) { + let where_ = DataWhereClauseRef::from(expr.copy()); + let ctx = match self.visit_where_clause(&where_, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + for decl in where_.declarations().iter() { + let rhs = DataExpression::new(decl.arg(1).protect()); + if let Some(result) = self.try_visit(&rhs.copy(), ctx)? { + return Ok(Some(result)); + } + } + if let Some(result) = self.try_visit(&where_.body(), ctx)? { + return Ok(Some(result)); + } + } else if is_machine_number(&expr.copy()) { + let number = DataMachineNumberRef::from(expr.copy()); + match self.visit_machine_number(&number, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(_) => {} // leaf + } + } else if is_untyped_identifier(&expr.copy()) { + let identifier = DataUntypedIdentifierRef::from(expr.copy()); + match self.visit_untyped_identifier(&identifier, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(_) => {} // leaf + } + } else { + unreachable!("Unknown data expression type"); + } + + Ok(None) + } +} + +/// Adapts a `FnMut` closure into a [`DataExpressionContextVisitor`]. +pub struct ClosureVisitor { + f: F, + _marker: PhantomData (T, E)>, +} + +impl ClosureVisitor { + pub fn new(f: F) -> Self { + ClosureVisitor { + f, + _marker: PhantomData, + } + } +} + +impl DataExpressionContextVisitor for ClosureVisitor +where + C: Copy, + F: FnMut(&DataExpressionRef<'_>, C) -> VisitResult, +{ + type Context = C; + type Break = T; + type Error = E; + + fn visit_variable(&mut self, var: &DataVariableRef<'_>, ctx: C) -> VisitResult { + (self.f)(&DataExpressionRef::from(var.copy()), ctx) + } + + fn visit_application(&mut self, appl: &DataApplicationRef<'_>, ctx: C) -> VisitResult { + (self.f)(&DataExpressionRef::from(appl.copy()), ctx) + } + + fn visit_abstraction(&mut self, abstraction: &DataAbstractionRef<'_>, ctx: C) -> VisitResult { + (self.f)(&DataExpressionRef::from(abstraction.copy()), ctx) + } + + fn visit_function_symbol(&mut self, fs: &DataFunctionSymbolRef<'_>, ctx: C) -> VisitResult { + (self.f)(&DataExpressionRef::from(fs.copy()), ctx) + } + + fn visit_where_clause(&mut self, where_: &DataWhereClauseRef<'_>, ctx: C) -> VisitResult { + (self.f)(&DataExpressionRef::from(where_.copy()), ctx) + } + + fn visit_machine_number(&mut self, number: &DataMachineNumberRef<'_>, ctx: C) -> VisitResult { + (self.f)(&DataExpressionRef::from(number.copy()), ctx) + } + + fn visit_untyped_identifier(&mut self, identifier: &DataUntypedIdentifierRef<'_>, ctx: C) -> VisitResult { + (self.f)(&DataExpressionRef::from(identifier.copy()), ctx) + } +} + +/// Visits subexpressions of a data expression top-down, routing through [`DataExpressionContextVisitor`]. +pub fn try_visit_data_expr_with(expr: &DataExpressionRef<'_>, ctx: C, visitor: F) -> Result, E> +where + C: Copy, + F: FnMut(&DataExpressionRef<'_>, C) -> VisitResult, +{ + ClosureVisitor::::new(visitor).try_visit(expr, ctx) +} + +/// Non-fallible convenience wrapper for [`try_visit_data_expr_with`]. +pub fn visit_data_expr_with(expr: &DataExpressionRef<'_>, ctx: C, mut visitor: F) -> Option +where + C: Copy, + F: FnMut(&DataExpressionRef<'_>, C) -> ControlFlow>, +{ + use std::convert::Infallible; + try_visit_data_expr_with(expr, ctx, |e, c| -> Result<_, Infallible> { Ok(visitor(e, c)) }).expect("infallible") +} + +/// Adapts a `FnMut` closure into a [`PbesExpressionContextVisitor`]. +pub struct PbesClosureVisitor { + f: F, + _marker: PhantomData (T, E)>, +} + +impl PbesClosureVisitor { + pub fn new(f: F) -> Self { + PbesClosureVisitor { + f, + _marker: PhantomData, + } + } +} + +/// Context-threading traversal over PBES expressions, consistent with [`DataExpressionContextVisitor`]. +/// +/// Data-expression leaves (e.g. condition terms) are passed to [`visit_data_expression`] but their +/// subterms are **not** recursed into automatically; use [`try_visit_data_expr_with`] +/// from inside [`visit_data_expression`] to continue into data subterms if desired. +/// +/// [`visit_data_expression`]: PbesExpressionContextVisitor::visit_data_expression +pub trait PbesExpressionContextVisitor { + type Context: Copy; + type Break; + type Error; + + fn visit_propositional_variable_instantiation( + &mut self, + _inst: &PbesPropositionalVariableInstantiationRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_not( + &mut self, + _not: &PbesNotRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_and( + &mut self, + _and: &PbesAndRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_or( + &mut self, + _or: &PbesOrRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_imp( + &mut self, + _imp: &PbesImpRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_forall( + &mut self, + _forall: &PbesForallRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn visit_exists( + &mut self, + _exists: &PbesExistsRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + /// Visits a data expression leaf. By default does not recurse into data subterms; + /// use [`try_visit_data_expr_with`] inside this method to do so. + fn visit_data_expression( + &mut self, + _expr: &DataExpressionRef<'_>, + ctx: Self::Context, + ) -> VisitResult { + Ok(ControlFlow::Continue(Descend::Descend(ctx))) + } + + fn try_visit( + &mut self, + expr: &PbesExpressionRef<'_>, + ctx: Self::Context, + ) -> Result, Self::Error> { + if is_pbes_propositional_variable_instantiation(&expr.copy()) { + let inst = PbesPropositionalVariableInstantiationRef::from(expr.copy()); + match self.visit_propositional_variable_instantiation(&inst, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(_) => {} // leaf + } + } else if is_data_expression(&expr.copy()) { + let data_expr: DataExpressionRef<'_> = expr.copy().into(); + match self.visit_data_expression(&data_expr, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(_) => {} // leaf + } + } else if is_pbes_and(&expr.copy()) { + let and = PbesAndRef::from(expr.copy()); + let ctx = match self.visit_and(&and, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + if let Some(result) = self.try_visit(&and.lhs(), ctx)? { + return Ok(Some(result)); + } + if let Some(result) = self.try_visit(&and.rhs(), ctx)? { + return Ok(Some(result)); + } + } else if is_pbes_or(&expr.copy()) { + let or = PbesOrRef::from(expr.copy()); + let ctx = match self.visit_or(&or, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + if let Some(result) = self.try_visit(&or.lhs(), ctx)? { + return Ok(Some(result)); + } + if let Some(result) = self.try_visit(&or.rhs(), ctx)? { + return Ok(Some(result)); + } + } else if is_pbes_imp(&expr.copy()) { + let imp = PbesImpRef::from(expr.copy()); + let ctx = match self.visit_imp(&imp, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + if let Some(result) = self.try_visit(&imp.lhs(), ctx)? { + return Ok(Some(result)); + } + if let Some(result) = self.try_visit(&imp.rhs(), ctx)? { + return Ok(Some(result)); + } + } else if is_pbes_not(&expr.copy()) { + let not = PbesNotRef::from(expr.copy()); + let ctx = match self.visit_not(¬, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + if let Some(result) = self.try_visit(¬.body(), ctx)? { + return Ok(Some(result)); + } + } else if is_pbes_forall(&expr.copy()) { + let forall = PbesForallRef::from(expr.copy()); + let ctx = match self.visit_forall(&forall, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + if let Some(result) = self.try_visit(&forall.body(), ctx)? { + return Ok(Some(result)); + } + } else if is_pbes_exists(&expr.copy()) { + let exists = PbesExistsRef::from(expr.copy()); + let ctx = match self.visit_exists(&exists, ctx)? { + ControlFlow::Break(result) => return Ok(Some(result)), + ControlFlow::Continue(Descend::Prune) => return Ok(None), + ControlFlow::Continue(Descend::Descend(ctx)) => ctx, + }; + if let Some(result) = self.try_visit(&exists.body(), ctx)? { + return Ok(Some(result)); + } + } else { + unreachable!("Unknown PBES expression type"); + } + + Ok(None) + } +} + +impl PbesExpressionContextVisitor for PbesClosureVisitor +where + C: Copy, + F: FnMut(&PbesExpressionRef<'_>, C) -> VisitResult, +{ + type Context = C; + type Break = T; + type Error = E; + + fn visit_propositional_variable_instantiation( + &mut self, + inst: &PbesPropositionalVariableInstantiationRef<'_>, + ctx: C, + ) -> VisitResult { + (self.f)(&PbesExpressionRef::from(inst.copy()), ctx) + } + + fn visit_not(&mut self, not: &PbesNotRef<'_>, ctx: C) -> VisitResult { + (self.f)(&PbesExpressionRef::from(not.copy()), ctx) + } + + fn visit_and(&mut self, and: &PbesAndRef<'_>, ctx: C) -> VisitResult { + (self.f)(&PbesExpressionRef::from(and.copy()), ctx) + } + + fn visit_or(&mut self, or: &PbesOrRef<'_>, ctx: C) -> VisitResult { + (self.f)(&PbesExpressionRef::from(or.copy()), ctx) + } + + fn visit_imp(&mut self, imp: &PbesImpRef<'_>, ctx: C) -> VisitResult { + (self.f)(&PbesExpressionRef::from(imp.copy()), ctx) + } + + fn visit_forall(&mut self, forall: &PbesForallRef<'_>, ctx: C) -> VisitResult { + (self.f)(&PbesExpressionRef::from(forall.copy()), ctx) + } + + fn visit_exists(&mut self, exists: &PbesExistsRef<'_>, ctx: C) -> VisitResult { + (self.f)(&PbesExpressionRef::from(exists.copy()), ctx) + } + + fn visit_data_expression(&mut self, expr: &DataExpressionRef<'_>, ctx: C) -> VisitResult { + (self.f)(&PbesExpressionRef::from(expr.copy()), ctx) + } +} + +/// Visits subexpressions of a PBES expression top-down, routing through [`PbesExpressionContextVisitor`]. +pub fn try_visit_pbes_expr_with(expr: &PbesExpressionRef<'_>, ctx: C, visitor: F) -> Result, E> +where + C: Copy, + F: FnMut(&PbesExpressionRef<'_>, C) -> VisitResult, +{ + PbesClosureVisitor::::new(visitor).try_visit(expr, ctx) +} + +/// Non-fallible convenience wrapper for [`try_visit_pbes_expr_with`]. +pub fn visit_pbes_expr_with(expr: &PbesExpressionRef<'_>, ctx: C, mut visitor: F) -> Option +where + C: Copy, + F: FnMut(&PbesExpressionRef<'_>, C) -> ControlFlow>, +{ + use std::convert::Infallible; + try_visit_pbes_expr_with(expr, ctx, |e, c| -> Result<_, Infallible> { Ok(visitor(e, c)) }).expect("infallible") +} diff --git a/tools/mcrl2/crates/merc_lps/Cargo.toml b/tools/mcrl2/crates/merc_lps/Cargo.toml new file mode 100644 index 000000000..4096bf8c3 --- /dev/null +++ b/tools/mcrl2/crates/merc_lps/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "merc_lps" +description = "Explicit and symbolic state space exploration for mCRL2 linear process specifications." +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[features] +# Tracks per-summand cache and control-flow pruning counters during exploration. +metrics = ["merc_explore/metrics"] + +[lints] +workspace = true + +[dependencies] +mcrl2.workspace = true +merc_explore = { workspace = true, features = ["clap"] } +merc_io.workspace = true +merc_lts = { workspace = true, features = ["clap"] } +merc_symbolic = { workspace = true, features = ["clap"] } +merc_unsafety.workspace = true +merc_utilities.workspace = true +oxidd.workspace = true + +log.workspace = true +rayon.workspace = true + +[dev-dependencies] +merc_reduction.workspace = true +merc_syntax.workspace = true +tempfile.workspace = true diff --git a/tools/mcrl2/lps/src/cfg_lps.rs b/tools/mcrl2/crates/merc_lps/src/cfg_lps.rs similarity index 92% rename from tools/mcrl2/lps/src/cfg_lps.rs rename to tools/mcrl2/crates/merc_lps/src/cfg_lps.rs index c0ee4fb23..d4d5e7891 100644 --- a/tools/mcrl2/lps/src/cfg_lps.rs +++ b/tools/mcrl2/crates/merc_lps/src/cfg_lps.rs @@ -2,6 +2,7 @@ use std::fmt; use mcrl2::LinearProcessSpecification; use merc_explore::LPS; +use merc_explore::Summand; use merc_utilities::MercError; use merc_utilities::ShardedCounter; @@ -21,7 +22,7 @@ use crate::explore_explicit::Mcrl2MultiActionLabel; /// Pruning never changes the explored transition system: a dropped summand has a /// guard that is false in the current state and would have produced no /// transitions anyway. -pub(crate) struct CfgLinearProcessSpecification { +pub struct CfgLinearProcessSpecification { /// The underlying explicit LPS that performs the actual enumeration. inner: ExplicitLinearProcessSpecification, @@ -48,7 +49,7 @@ struct SummandCfgCounters { impl CfgLinearProcessSpecification { /// Builds the explicit LPS and runs the control flow graph analysis on top. - pub(crate) fn new(lps: &LinearProcessSpecification) -> Result { + pub fn new(lps: &LinearProcessSpecification) -> Result { let inner = ExplicitLinearProcessSpecification::new(lps)?; let analysis = ControlFlowAnalysis::new(&inner); let summand_metrics = (0..analysis.source_constraints.len()) @@ -63,7 +64,7 @@ impl CfgLinearProcessSpecification { /// Returns the process parameter indices identified as control flow /// parameters. - pub(crate) fn control_flow_parameters(&self) -> &[usize] { + pub fn control_flow_parameters(&self) -> &[usize] { &self.analysis.control_flow_parameters } @@ -72,7 +73,7 @@ impl CfgLinearProcessSpecification { /// The returned [`CfgMetrics`] implements [`fmt::Display`] for a /// human-readable summary of how often each summand was selected versus /// pruned. - pub(crate) fn metrics(&self) -> CfgMetrics { + pub fn metrics(&self) -> CfgMetrics { let summands = self .summand_metrics .iter() @@ -90,7 +91,7 @@ impl CfgLinearProcessSpecification { /// Control flow pruning metrics for a single summand. #[derive(Clone, Copy, Debug)] -pub(crate) struct SummandCfgMetrics { +pub struct SummandCfgMetrics { /// Index of the summand in the LPS. pub index: usize, /// Number of states in which this summand was selected (explored). @@ -101,14 +102,14 @@ pub(crate) struct SummandCfgMetrics { impl SummandCfgMetrics { /// Total number of states for which this summand was evaluated. - pub(crate) fn evaluated(&self) -> u64 { + pub fn evaluated(&self) -> u64 { self.selected + self.pruned } /// Fraction of evaluations in which this summand was pruned, in `[0.0, 1.0]`. /// /// Returns `0.0` when the summand was never evaluated. - pub(crate) fn prune_rate(&self) -> f64 { + pub fn prune_rate(&self) -> f64 { let evaluated = self.evaluated(); if evaluated == 0 { 0.0 @@ -121,24 +122,24 @@ impl SummandCfgMetrics { /// Aggregated control flow pruning metrics for every summand of a /// [`CfgLinearProcessSpecification`]. #[derive(Clone, Debug)] -pub(crate) struct CfgMetrics { +pub struct CfgMetrics { /// Per-summand metrics, ordered by summand index. pub summands: Vec, } impl CfgMetrics { /// Total number of summand selections across all summands. - pub(crate) fn total_selected(&self) -> u64 { + pub fn total_selected(&self) -> u64 { self.summands.iter().map(|s| s.selected).sum() } /// Total number of summand prunings across all summands. - pub(crate) fn total_pruned(&self) -> u64 { + pub fn total_pruned(&self) -> u64 { self.summands.iter().map(|s| s.pruned).sum() } /// Fraction of evaluations pruned across all summands. - pub(crate) fn prune_rate(&self) -> f64 { + pub fn prune_rate(&self) -> f64 { let pruned = self.total_pruned(); let evaluated = pruned + self.total_selected(); if evaluated == 0 { @@ -235,5 +236,5 @@ impl LPS for CfgLinearProcessSpecification { }) } - fn state_info(&self, _state: &[Self::Value]) -> Self::StateInfo {} + fn state_info(&self, _state: &[Self::Value], _context: &::Context) -> Self::StateInfo {} } diff --git a/tools/mcrl2/lps/src/control_flow.rs b/tools/mcrl2/crates/merc_lps/src/control_flow.rs similarity index 98% rename from tools/mcrl2/lps/src/control_flow.rs rename to tools/mcrl2/crates/merc_lps/src/control_flow.rs index 104480c4f..71ebc1057 100644 --- a/tools/mcrl2/lps/src/control_flow.rs +++ b/tools/mcrl2/crates/merc_lps/src/control_flow.rs @@ -34,14 +34,14 @@ use crate::explore_explicit::ExplicitSummand; /// Under these conditions the value of a CFP in any reachable state is one of a /// statically known set of constants, so a summand whose guard requires /// `d == c` can only fire from states where `d` already equals `c`. -pub(crate) struct ControlFlowAnalysis { +pub struct ControlFlowAnalysis { /// The process parameter indices identified as control flow parameters. - pub(crate) control_flow_parameters: Vec, + pub control_flow_parameters: Vec, /// For each summand, the source-value constraints on the control flow /// parameters: each `(position, value)` requires the state's value at /// `control_flow_parameters[position]` to equal the interned `value`. - pub(crate) source_constraints: Vec>, + pub source_constraints: Vec>, } impl ControlFlowAnalysis { @@ -50,7 +50,7 @@ impl ControlFlowAnalysis { /// The source values are interned into the same value mapping (and under the /// same normalisation) that `lps` uses for its state vectors, so the indices /// can be compared directly against state entries. - pub(crate) fn new(lps: &ExplicitLinearProcessSpecification) -> Self { + pub fn new(lps: &ExplicitLinearProcessSpecification) -> Self { let parameters = lps.parameters(); // Certain preprocessing steps (notably diff --git a/tools/mcrl2/lps/src/explore_explicit.rs b/tools/mcrl2/crates/merc_lps/src/explore_explicit.rs similarity index 96% rename from tools/mcrl2/lps/src/explore_explicit.rs rename to tools/mcrl2/crates/merc_lps/src/explore_explicit.rs index d731bd118..f7d7d91d3 100644 --- a/tools/mcrl2/lps/src/explore_explicit.rs +++ b/tools/mcrl2/crates/merc_lps/src/explore_explicit.rs @@ -26,6 +26,7 @@ use merc_explore::CacheLPS; use merc_explore::CachingStrategy; use merc_explore::ExplorationStrategy; use merc_explore::LPS; +use merc_explore::StateEffect; use merc_explore::Summand; use merc_explore::configure_rayon_thread_pool; use merc_explore::explore; @@ -43,7 +44,7 @@ use crate::cfg_lps::CfgLinearProcessSpecification; /// Periodic progress reporter for LPS exploration, printing the number of /// discovered states and transitions. -pub(crate) fn lps_progress() -> TimeProgress<(usize, usize)> { +pub fn lps_progress() -> TimeProgress<(usize, usize)> { TimeProgress::new( |(states, transitions): (usize, usize)| { info!("Explored {states} states, {transitions} transitions..."); @@ -61,7 +62,7 @@ pub(crate) fn lps_progress() -> TimeProgress<(usize, usize)> { /// changes the explored transition system. /// /// [`ControlFlowAnalysis`]: crate::control_flow::ControlFlowAnalysis -pub(crate) fn explore_lps_explicit( +pub fn explore_lps_explicit( builder: &mut B, lps: &LinearProcessSpecification, caching: CachingStrategy, @@ -162,7 +163,7 @@ where /// Explores the linear process specification explicitly in parallel across /// `threads` worker threads, streaming the discovered transitions into /// `builder`. -pub(crate) fn explore_lps_explicit_parallel( +pub fn explore_lps_explicit_parallel( builder: &mut B, lps: &LinearProcessSpecification, caching: CachingStrategy, @@ -285,7 +286,7 @@ where /// enumeration cache and the concurrent LTS builder. Display uses mCRL2's /// pretty-printer. #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub(crate) struct Mcrl2MultiActionLabel { +pub struct Mcrl2MultiActionLabel { term: ATermSend, } @@ -344,7 +345,7 @@ fn is_mcrl2_timed_multi_action_term(term: &ATermSend) -> bool { /// through the [`Protected`] wrapper, so the mapping is globally consistent and /// safe to populate from several worker threads at once. Labels are the printed /// multi-actions of the summands. -pub(crate) struct ExplicitLinearProcessSpecification { +pub struct ExplicitLinearProcessSpecification { /// The (preprocessed) underlying LPS. Retained because each per-thread /// [`ExplicitContext`] builds its own [`LearnSuccessorsContext`] from it. lps: LinearProcessSpecification, @@ -376,7 +377,7 @@ type ValueMapping = ConcurrentIndexedSet>; unsafe impl Sync for ExplicitLinearProcessSpecification {} impl ExplicitLinearProcessSpecification { - pub(crate) fn new(lps: &LinearProcessSpecification) -> Result { + pub fn new(lps: &LinearProcessSpecification) -> Result { let lps = preprocess(lps, &PreprocessOptions::default())?; let parameters = lps.parameters(); @@ -436,12 +437,12 @@ impl ExplicitLinearProcessSpecification { } /// The process parameter variables in declaration order. - pub(crate) fn parameters(&self) -> Vec { + pub fn parameters(&self) -> Vec { self.lps.parameters().to_vec() } /// The (preprocessed) underlying linear process specification. - pub(crate) fn lps(&self) -> &LinearProcessSpecification { + pub fn lps(&self) -> &LinearProcessSpecification { &self.lps } @@ -451,7 +452,7 @@ impl ExplicitLinearProcessSpecification { /// The rewriting and interning mirror the construction of the initial state /// vector, so the returned index can be compared directly against the /// entries of explored state vectors. - pub(crate) fn intern_normal_form(&self, context: &LearnSuccessorsContext, value: &DataExpressionRef) -> usize { + pub fn intern_normal_form(&self, context: &LearnSuccessorsContext, value: &DataExpressionRef) -> usize { let rewritten = context.rewrite_under_sigma(value); // SAFETY: the rewritten term is interned into `self.value_mapping`, a @@ -468,7 +469,7 @@ impl ExplicitLinearProcessSpecification { /// Owns the mCRL2 enumeration backend and the reusable scratch buffers, so the /// LPS and its summands stay immutable and shareable by `&self` while each /// worker thread drives its own context. -pub(crate) struct ExplicitContext { +pub struct ExplicitContext { /// Backend used by mCRL2 to perform the enumeration, staged per source /// state by [`LPS::prepare`] and consumed by [`Summand::enumerate`]. context: LearnSuccessorsContext, @@ -487,7 +488,7 @@ pub(crate) struct ExplicitContext { unsafe impl Send for ExplicitContext {} /// A single summand of the LPS, prepared for explicit enumeration. -pub(crate) struct ExplicitSummand { +pub struct ExplicitSummand { /// The indices of the parameters that this summand reads. read_indices: Vec, @@ -589,12 +590,12 @@ impl ExplicitSummand { } /// The condition (guard) of this summand. - pub(crate) fn condition(&self) -> &DataExpression { + pub fn condition(&self) -> &DataExpression { &self.condition } /// The non-identity assignments (write parameters) of this summand. - pub(crate) fn write_assignments(&self) -> &ATermList { + pub fn write_assignments(&self) -> &ATermList { &self.write_assignments } } @@ -650,7 +651,7 @@ impl LPS for ExplicitLinearProcessSpecification { 0..self.summands.len() } - fn state_info(&self, _state: &[Self::Value]) -> Self::StateInfo {} + fn state_info(&self, _state: &[Self::Value], _context: &::Context) -> Self::StateInfo {} } impl Summand for ExplicitSummand { @@ -662,8 +663,8 @@ impl Summand for ExplicitSummand { &self.read_indices } - fn write_positions(&self) -> &[usize] { - &self.write_indices + fn effect(&self) -> StateEffect<'_> { + StateEffect::Positions(&self.write_indices) } fn enumerate(&self, context: &mut Self::Context, state: &[usize], mut report: F) -> Result<(), MercError> diff --git a/tools/mcrl2/lps/src/explore_symbolic.rs b/tools/mcrl2/crates/merc_lps/src/explore_symbolic.rs similarity index 98% rename from tools/mcrl2/lps/src/explore_symbolic.rs rename to tools/mcrl2/crates/merc_lps/src/explore_symbolic.rs index d17fb2312..6109a8cd8 100644 --- a/tools/mcrl2/lps/src/explore_symbolic.rs +++ b/tools/mcrl2/crates/merc_lps/src/explore_symbolic.rs @@ -18,7 +18,7 @@ use crate::explore_explicit::ExplicitLinearProcessSpecification; /// from the explicit [`ExplicitLinearProcessSpecification`] via the generic /// [`SymbolicLps`] adapter, so LPS and PBES symbolic exploration share one /// implementation. -pub(crate) fn explore_lps_symbolic( +pub fn explore_lps_symbolic( storage: &LDDManagerRef, lps: &LinearProcessSpecification, strategy: ExplorationStrategy, @@ -72,7 +72,7 @@ mod tests { let temp_dir = tempfile::tempdir().unwrap(); let lps_path = temp_dir.path().join("abp.lps"); - let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../examples/mCRL2/academic/abp/abp.mcrl2"); + let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../../examples/mCRL2/academic/abp/abp.mcrl2"); // Run mcrl22lps on the ABP example to get an LPS file. let status = Command::new(&mcrl22lps) diff --git a/tools/mcrl2/crates/merc_lps/src/lib.rs b/tools/mcrl2/crates/merc_lps/src/lib.rs new file mode 100644 index 000000000..9a9a0c329 --- /dev/null +++ b/tools/mcrl2/crates/merc_lps/src/lib.rs @@ -0,0 +1,31 @@ +//! +//! State space exploration for mCRL2 linear process specifications (LPSs). +//! +//! The [`merc-lps`] binary is a thin command line wrapper around this crate: it +//! parses the arguments, reads the LPS and then calls into one of the explorers +//! defined here. +//! +//! - [`explore_lps_explicit`] and [`explore_lps_explicit_parallel`] enumerate the +//! state space explicitly, feeding the discovered transitions into an +//! [`merc_lts::LtsBuilder`]. +//! - [`explore_lps_symbolic`] performs LDD-based symbolic reachability. +//! - [`CfgLinearProcessSpecification`] wraps an LPS with a control flow analysis +//! that prunes summands whose control flow guard cannot hold in the current +//! state. +//! +//! [`merc-lps`]: https://mercorg.github.io/merc-website/ + +pub mod cfg_lps; +pub mod control_flow; +pub mod explore_explicit; +pub mod explore_symbolic; + +pub use cfg_lps::CfgLinearProcessSpecification; +pub use cfg_lps::CfgMetrics; +pub use cfg_lps::SummandCfgMetrics; +pub use control_flow::ControlFlowAnalysis; +pub use explore_explicit::ExplicitLinearProcessSpecification; +pub use explore_explicit::Mcrl2MultiActionLabel; +pub use explore_explicit::explore_lps_explicit; +pub use explore_explicit::explore_lps_explicit_parallel; +pub use explore_symbolic::explore_lps_symbolic; diff --git a/tools/mcrl2/crates/merc_lps/tests/cfg_lps_test.rs b/tools/mcrl2/crates/merc_lps/tests/cfg_lps_test.rs new file mode 100644 index 000000000..db8b00a90 --- /dev/null +++ b/tools/mcrl2/crates/merc_lps/tests/cfg_lps_test.rs @@ -0,0 +1,164 @@ +use std::fs::File; +use std::path::Path; +use std::process::Command; + +use mcrl2::read_lps; +use merc_explore::CachingStrategy; +use merc_explore::ExplorationStrategy; +use merc_io::temp_dir; +use merc_io::traced_command; +use merc_lts::LTS; +use merc_lts::LtsBuilderFast; +use merc_lts::StateIndex; +use merc_reduction::Equivalence; +use merc_reduction::compare_lts; +use merc_syntax::random_lps; +use merc_utilities::Timing; +use merc_utilities::random_test; + +use merc_lps::Mcrl2MultiActionLabel; +use merc_lps::explore_lps_explicit; + +/// Explores `lps_path` both with the plain explicit explorer and with the +/// control-flow-pruning explorer, and asserts the two LTSs have equal +/// state/transition counts and are strongly bisimilar. Pruning summands +/// whose guard cannot hold must never change the explored transition system. +fn assert_cfg_matches_explicit(lps_path: &Path) { + let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); + + let mut reference_builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); + explore_lps_explicit( + &mut reference_builder, + &lps, + CachingStrategy::None, + ExplorationStrategy::Dfs, + false, + &Timing::new(), + ) + .expect("Explicit exploration failed"); + let reference = reference_builder + .finish(StateIndex::new(0), false) + .relabel(|label| Ok(label.to_string())) + .unwrap(); + + let mut cfg_builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); + explore_lps_explicit( + &mut cfg_builder, + &lps, + CachingStrategy::None, + ExplorationStrategy::Dfs, + true, + &Timing::new(), + ) + .expect("Control flow exploration failed"); + let cfg = cfg_builder + .finish(StateIndex::new(0), false) + .relabel(|label| Ok(label.to_string())) + .unwrap(); + + assert_eq!( + reference.num_of_states(), + cfg.num_of_states(), + "State count mismatch for {}", + lps_path.display() + ); + assert_eq!( + reference.num_of_transitions(), + cfg.num_of_transitions(), + "Transition count mismatch for {}", + lps_path.display() + ); + assert!( + compare_lts(Equivalence::StrongBisim, reference, cfg, false, false, &Timing::new()).0, + "Control flow and explicit LTSs are not strongly bisimilar for {}", + lps_path.display() + ); +} + +/// Runs `mcrl22lps` on a `.mcrl2` specification and asserts that control-flow +/// and plain explicit exploration agree. +fn compare_cfg_with_explicit(spec_relative_path: &str) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); + let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); + assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); + + let temp_dir = temp_dir("test_cfg_lps").unwrap(); + let lps_path = temp_dir.path().join("spec.lps"); + + let status = + traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)).expect("Failed to execute mcrl22lps"); + assert!(status.success(), "mcrl22lps failed with status: {status}"); + + assert_cfg_matches_explicit(&lps_path); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_cfg_abp() { + compare_cfg_with_explicit("../../../../examples/mCRL2/academic/abp/abp.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_cfg_cabp() { + compare_cfg_with_explicit("../../../../examples/mCRL2/academic/cabp/cabp.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_cfg_allow() { + compare_cfg_with_explicit("../../../../examples/mCRL2/academic/allow/allow.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_cfg_block() { + compare_cfg_with_explicit("../../../../examples/mCRL2/academic/block/block.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_cfg_dining3() { + compare_cfg_with_explicit("../../../../examples/mCRL2/academic/dining/dining3.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_cfg_par() { + compare_cfg_with_explicit("../../../../examples/mCRL2/academic/par/par.mcrl2"); +} + +/// Generates random LPS specs with [`random_lps`] and asserts that +/// control-flow and plain explicit exploration agree on each. +#[test] +#[cfg_attr(miri, ignore)] +fn test_cfg_random_lps() { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let txt2lps = Path::new(&mcrl2_path).join("txt2lps"); + + let temp_dir = temp_dir("test_cfg_random_lps").unwrap(); + let spec_path = temp_dir.path().join("spec.mcrl2"); + let lps_path = temp_dir.path().join("spec.lps"); + + random_test(20, |rng| { + let spec = random_lps(rng, 5, 3, 0.4); + std::fs::write(&spec_path, spec.to_string()).expect("Failed to write random LPS spec"); + + let status = + traced_command(Command::new(&txt2lps).arg(&spec_path).arg(&lps_path)).expect("Failed to execute txt2lps"); + assert!(status.success(), "txt2lps failed with status: {status}"); + + // Ensure the generated LPS can be read back before exploring it. + let _ = File::open(&lps_path).expect("Failed to open generated LPS"); + assert_cfg_matches_explicit(&lps_path); + }); +} diff --git a/tools/mcrl2/crates/merc_lps/tests/explore_lps_test.rs b/tools/mcrl2/crates/merc_lps/tests/explore_lps_test.rs new file mode 100644 index 000000000..7a3b145b2 --- /dev/null +++ b/tools/mcrl2/crates/merc_lps/tests/explore_lps_test.rs @@ -0,0 +1,393 @@ +use std::fs::File; +use std::io::Cursor; +use std::path::Path; +use std::process::Command; + +use mcrl2::read_lps; +use merc_explore::CachingStrategy; +use merc_explore::ExplorationStrategy; +use merc_io::temp_dir; +use merc_io::traced_command; +use merc_lts::AutStream; +use merc_lts::LTS; +use merc_lts::LtsBuilderFast; +use merc_lts::LtsMultiAction; +use merc_lts::MutexLtsBuilder; +use merc_lts::SimpleAction; +use merc_lts::StateIndex; +use merc_lts::read_mcrl2_aut; +use merc_lts::write_mcrl2_aut; +use merc_reduction::Equivalence; +use merc_reduction::compare_lts; +use merc_syntax::random_lps; +use merc_utilities::Timing; +use merc_utilities::random_test; + +use merc_lps::Mcrl2MultiActionLabel; +use merc_lps::explore_lps_explicit; +use merc_lps::explore_lps_explicit_parallel; + +/// Runs `mcrl22lps` and `lps2lts` on a `.mcrl2` specification, explores the +/// LPS with `explore_lps_explicit`, and asserts strong bisimilarity between +/// the two resulting LTSs. +fn compare_with_lps2lts(spec_relative_path: &str) { + compare_with_lps2lts_caching(spec_relative_path, CachingStrategy::None); +} + +/// Like [`compare_with_lps2lts`] but explores the LPS with the given +/// [`CachingStrategy`]. +fn compare_with_lps2lts_caching(spec_relative_path: &str, strategy: CachingStrategy) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); + let lps2lts = Path::new(&mcrl2_path).join("lps2lts"); + + let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); + assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); + + let temp_dir = temp_dir("test_explore_lps").unwrap(); + let lps_path = temp_dir.path().join("spec.lps"); + let aut_path = temp_dir.path().join("reference.aut"); + + let status = + traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)).expect("Failed to execute mcrl22lps"); + assert!(status.success(), "mcrl22lps failed with status: {status}"); + + let status = + traced_command(Command::new(&lps2lts).arg(&lps_path).arg(&aut_path)).expect("Failed to execute lps2lts"); + assert!(status.success(), "lps2lts failed with status: {status}"); + + // Parse the labels as multi-actions (a multiset of actions) rather than + // comparing the pretty-printed strings verbatim: mCRL2 does not + // guarantee a canonical order for the `|`-separated actions of a + // multi-action, so two equivalent multi-actions (e.g. reached via + // different summands, or printed by a different tool) can be printed + // with their actions in a different order. + let reference_lts = read_mcrl2_aut(File::open(&aut_path).unwrap()) + .expect("Failed to read reference .aut") + .relabel(|label| LtsMultiAction::::from_string(&label)) + .unwrap(); + + let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); + let mut builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); + explore_lps_explicit( + &mut builder, + &lps, + strategy, + ExplorationStrategy::Dfs, + false, + &Timing::new(), + ) + .expect("Failed to explore LPS"); + let result_lts = builder.finish(StateIndex::new(0), false); + + write_mcrl2_aut( + &mut File::create(temp_dir.path().join("result.aut")).unwrap(), + &result_lts, + ) + .expect("Failed to write result .aut"); + + assert_eq!( + reference_lts.num_of_states(), + result_lts.num_of_states(), + "State count mismatch for {spec_relative_path} with {strategy:?}" + ); + assert_eq!( + reference_lts.num_of_transitions(), + result_lts.num_of_transitions(), + "Transition count mismatch for {spec_relative_path} with {strategy:?}" + ); + assert!( + compare_lts( + Equivalence::StrongBisim, + reference_lts, + result_lts + .relabel(|label| LtsMultiAction::::from_string(&label.to_string())) + .unwrap(), + false, + false, + &Timing::new(), + ) + .0, + "LTSs are not strongly bisimilar for {spec_relative_path} with {strategy:?}" + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_abp() { + compare_with_lps2lts("../../../../examples/mCRL2/academic/abp/abp.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_cabp() { + compare_with_lps2lts("../../../../examples/mCRL2/academic/cabp/cabp.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_allow() { + compare_with_lps2lts("../../../../examples/mCRL2/academic/allow/allow.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_block() { + compare_with_lps2lts("../../../../examples/mCRL2/academic/block/block.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_dining3() { + compare_with_lps2lts("../../../../examples/mCRL2/academic/dining/dining3.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_par() { + compare_with_lps2lts("../../../../examples/mCRL2/academic/par/par.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_abp_local_cache() { + compare_with_lps2lts_caching( + "../../../../examples/mCRL2/academic/abp/abp.mcrl2", + CachingStrategy::Local, + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_cabp_local_cache() { + compare_with_lps2lts_caching( + "../../../../examples/mCRL2/academic/cabp/cabp.mcrl2", + CachingStrategy::Local, + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_dining3_local_cache() { + compare_with_lps2lts_caching( + "../../../../examples/mCRL2/academic/dining/dining3.mcrl2", + CachingStrategy::Local, + ); +} + +/// Generates random LPS specs using [`random_lps`], explores them with +/// [`explore_lps_explicit`], and asserts strong bisimilarity against `lps2lts`. +/// +/// Random specs are written to a temp file and converted with `txt2lps` (no +/// linearisation step required for the FSM-shaped output of [`random_lps`]). +fn compare_random_lps_with_lps2lts(strategy: CachingStrategy) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let txt2lps = Path::new(&mcrl2_path).join("txt2lps"); + let lps2lts = Path::new(&mcrl2_path).join("lps2lts"); + + let temp_dir = temp_dir("test_explore_random_lps").unwrap(); + let spec_path = temp_dir.path().join("spec.mcrl2"); + let lps_path = temp_dir.path().join("spec.lps"); + let aut_path = temp_dir.path().join("reference.aut"); + + random_test(20, |rng| { + let spec = random_lps(rng, 5, 3, 0.4); + std::fs::write(&spec_path, spec.to_string()).expect("Failed to write random LPS spec"); + + let status = + traced_command(Command::new(&txt2lps).arg(&spec_path).arg(&lps_path)).expect("Failed to execute txt2lps"); + assert!(status.success(), "txt2lps failed with status: {status}"); + + let status = + traced_command(Command::new(&lps2lts).arg(&lps_path).arg(&aut_path)).expect("Failed to execute lps2lts"); + assert!(status.success(), "lps2lts failed with status: {status}"); + + let reference_lts = read_mcrl2_aut(File::open(&aut_path).unwrap()).expect("Failed to read reference .aut"); + + let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); + let mut builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); + explore_lps_explicit( + &mut builder, + &lps, + strategy, + ExplorationStrategy::Dfs, + false, + &Timing::new(), + ) + .expect("Failed to explore LPS"); + let result_lts = builder.finish(StateIndex::new(0), false); + + assert_eq!( + reference_lts.num_of_states(), + result_lts.num_of_states(), + "State count mismatch with {strategy:?}" + ); + assert_eq!( + reference_lts.num_of_transitions(), + result_lts.num_of_transitions(), + "Transition count mismatch with {strategy:?}" + ); + assert!( + compare_lts( + Equivalence::StrongBisim, + reference_lts, + result_lts.relabel(|label| { Ok(label.to_string()) }).unwrap(), + false, + false, + &Timing::new(), + ) + .0, + "LTSs are not strongly bisimilar with {strategy:?}" + ); + }); +} + +/// Explores `lps_path` both sequentially and with the parallel work-stealing +/// search on several threads, and asserts the two LTSs are strongly +/// bisimilar. The parallel explorer numbers states sparsely (see the +/// assertion below), so its LTS carries extra unreachable deadlock states and +/// the comparison is only up to bisimulation rather than on exact counts. +fn assert_parallel_matches_sequential(lps_path: &Path) { + let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); + + // Sequential reference, relabelled to strings for comparison. + let mut builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); + explore_lps_explicit( + &mut builder, + &lps, + CachingStrategy::None, + ExplorationStrategy::Bfs, + false, + &Timing::new(), + ) + .expect("Sequential exploration failed"); + let sequential = builder + .finish(StateIndex::new(0), false) + .relabel(|label| Ok(label.to_string())) + .unwrap(); + + // Parallel exploration across several threads, streamed into an + // in-memory AUT buffer (guarded by a `MutexLtsBuilder`) and read back as + // a string-labelled LTS. + let mut buffer = Cursor::new(Vec::new()); + { + let mut builder = MutexLtsBuilder::new(AutStream::new_mcrl2(&mut buffer).unwrap()); + explore_lps_explicit_parallel( + &mut builder, + &lps, + CachingStrategy::None, + 4, + false, + false, + &Timing::new(), + ) + .expect("Parallel exploration failed"); + } + buffer.set_position(0); + let parallel = read_mcrl2_aut(&mut buffer).expect("Failed to read parallel AUT output"); + + // The parallel explorer numbers states sparsely (see `explore_parallel`), + // so its LTS has extra unreachable deadlock states: compare up to + // bisimulation rather than on exact state counts. + assert!( + compare_lts( + Equivalence::StrongBisim, + parallel, + sequential, + false, + false, + &Timing::new() + ) + .0, + "Parallel and sequential LTSs are not strongly bisimilar for {}", + lps_path.display() + ); +} + +/// Runs `mcrl22lps` on a `.mcrl2` specification and asserts that parallel and +/// sequential explicit exploration agree. +fn compare_parallel_with_sequential(spec_relative_path: &str) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); + let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); + assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); + + let temp_dir = temp_dir("test_explore_lps_parallel").unwrap(); + let lps_path = temp_dir.path().join("spec.lps"); + + let status = + traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)).expect("Failed to execute mcrl22lps"); + assert!(status.success(), "mcrl22lps failed with status: {status}"); + + assert_parallel_matches_sequential(&lps_path); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_parallel_abp() { + compare_parallel_with_sequential("../../../../examples/mCRL2/academic/abp/abp.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_parallel_cabp() { + compare_parallel_with_sequential("../../../../examples/mCRL2/academic/cabp/cabp.mcrl2"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_parallel_dining3() { + compare_parallel_with_sequential("../../../../examples/mCRL2/academic/dining/dining3.mcrl2"); +} + +/// Generates random LPS specs with [`random_lps`] and asserts that parallel +/// and sequential exploration agree on each. +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_explore_parallel_random_lps() { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let txt2lps = Path::new(&mcrl2_path).join("txt2lps"); + + let temp_dir = temp_dir("test_explore_random_lps_parallel").unwrap(); + let spec_path = temp_dir.path().join("spec.mcrl2"); + let lps_path = temp_dir.path().join("spec.lps"); + + random_test(20, |rng| { + let spec = random_lps(rng, 5, 3, 0.4); + std::fs::write(&spec_path, spec.to_string()).expect("Failed to write random LPS spec"); + + let status = + traced_command(Command::new(&txt2lps).arg(&spec_path).arg(&lps_path)).expect("Failed to execute txt2lps"); + assert!(status.success(), "txt2lps failed with status: {status}"); + + assert_parallel_matches_sequential(&lps_path); + }); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_random_lps() { + compare_random_lps_with_lps2lts(CachingStrategy::None); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_random_lps_local_cache() { + compare_random_lps_with_lps2lts(CachingStrategy::Local); +} diff --git a/tools/mcrl2/crates/merc_pbes/Cargo.toml b/tools/mcrl2/crates/merc_pbes/Cargo.toml new file mode 100644 index 000000000..257309e32 --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "merc_pbes" +description = "Exploration, symmetry detection and symmetry reduction for mCRL2 parameterised boolean equation systems." +edition.workspace = true +license.workspace = true +rust-version.workspace = true +version.workspace = true + +[lints] +workspace = true + +[dependencies] +mcrl2.workspace = true +merc_explore = { workspace = true, features = ["clap"] } +merc_io.workspace = true +merc_lts = { workspace = true, features = ["clap"] } +merc_symbolic = { workspace = true, features = ["clap"] } +merc_unsafety.workspace = true +merc_utilities.workspace = true +merc_vpg = { workspace = true, features = ["clap"] } +oxidd.workspace = true + +duct.workspace = true +itertools.workspace = true +log.workspace = true +petgraph.workspace = true + +[dev-dependencies] +merc_syntax.workspace = true +rand.workspace = true +test-case.workspace = true diff --git a/tools/mcrl2/crates/merc_pbes/src/bsgs.rs b/tools/mcrl2/crates/merc_pbes/src/bsgs.rs new file mode 100644 index 000000000..6a3a3297d --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/src/bsgs.rs @@ -0,0 +1,1223 @@ +/// Authors: Menno Bartels and Maurice Laveaux +use std::collections::HashMap; +use std::collections::VecDeque; +use std::collections::hash_map::Entry; + +use merc_utilities::MercError; + +use crate::graph_symmetry::GapConfig; +use crate::graph_symmetry::run_gap; +use crate::permutation::Permutation; + +/// A permutation stored as a dense image vector: `images[i] = π(i)`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DensePermutation { + images: Vec, +} + +impl DensePermutation { + pub(crate) fn identity(n: usize) -> Self { + DensePermutation { + images: (0..n).collect(), + } + } + + pub(crate) fn from_permutation(perm: &Permutation, n: usize) -> Self { + DensePermutation { + images: (0..n).map(|i| perm.value(i)).collect(), + } + } + + /// `self.apply(x)` returns `π(x)`. + pub(crate) fn apply(&self, x: usize) -> usize { + self.images[x] + } + + /// Returns the result of permuting the *positions* of `v`, i.e. + /// `result[π(i)] = v[i]` ↔ `result[i] = v[π⁻¹(i)]`. + #[cfg(test)] + pub(crate) fn apply_to_vec(&self, v: &[usize]) -> Vec { + debug_assert_eq!( + v.len(), + self.images.len(), + "permutation degree must match vector length" + ); + let n = v.len(); + let mut result = vec![0usize; n]; + for i in 0..n { + result[self.images[i]] = v[i]; + } + result + } + + /// Compose: returns the permutation `α ∘ β` where `β` is applied first. + /// `compose(α, β).apply(x) == α.apply(β.apply(x))`. + pub(crate) fn compose(alpha: &DensePermutation, beta: &DensePermutation) -> Self { + debug_assert_eq!(alpha.images.len(), beta.images.len()); + let images = beta.images.iter().map(|&x| alpha.apply(x)).collect(); + DensePermutation { images } + } + + pub(crate) fn inverse(&self) -> Self { + let n = self.images.len(); + let mut inv = vec![0usize; n]; + for (i, &img) in self.images.iter().enumerate() { + inv[img] = i; + } + DensePermutation { images: inv } + } + + pub(crate) fn len(&self) -> usize { + self.images.len() + } +} + +/// One level of a stabilizer chain. +pub(crate) struct SchreierLevel { + /// The point β fixed by the stabilizer at this level. + pub(crate) base_point: usize, + + /// The coset representatives as `(x, u)` pairs, where `x` is an orbit point + /// and `u` the unique representative with `u(base_point) == x`. + /// + /// A flat vector rather than a map because canonicalization only ever scans + /// it in order, and needs `x` alongside `u` to score a candidate. + pub(crate) transversal: Vec<(usize, DensePermutation)>, +} + +impl SchreierLevel { + /// Builds a level from a transversal keyed on orbit point. + fn new(base_point: usize, transversal: HashMap) -> Self { + SchreierLevel { + base_point, + transversal: transversal.into_iter().collect(), + } + } +} + +/// Reusable buffers owned by the caller, so that canonicalizing a state +/// allocates nothing. +#[derive(Default)] +pub(crate) struct CanonicalizeContext { + /// The candidate prefixes of the current level, concatenated as image + /// vectors: candidate `k` is `current[k * n..(k + 1) * n]`. + current: Vec, + + /// The same arena for the level being built; swapped with `current` per level. + next: Vec, + + /// Indices `(candidate, transversal slot)` of the pairs that achieve the + /// level's best value. + survivors: Vec<(usize, usize)>, + + /// The lex-least full image found in the final comparison. + best: Vec, +} + +/// A base and strong generating set, stored as a stabilizer chain. +pub struct Bsgs { + /// Degree of the permutation group; every point is in `0..n`. + pub(crate) n: usize, + + /// The stabilizer chain, outermost level first. Empty for the trivial group. + pub(crate) chain: Vec, +} + +impl Bsgs { + /// Build a BSGS by invoking GAP with the `ExplicitBSGS` script. + /// Falls back to the local Schreier–Sims construction if GAP is not available. + pub fn from_generators(gens: &[Permutation], n: usize, config: &GapConfig) -> Result { + if gens.is_empty() { + return Ok(Bsgs { n, chain: vec![] }); + } + + match bsgs_from_gap(gens, n, config) { + Ok(bsgs) => Ok(bsgs), + Err(gap_err) => { + log::warn!("GAP not available for BSGS ({gap_err}); falling back to local Schreier–Sims"); + bsgs_schreier_sims(gens, n) + } + } + } + + /// Product of transversal sizes — equals |G|. + pub fn order(&self) -> u128 { + self.chain.iter().map(|l| l.transversal.len() as u128).product() + } + + /// BFS over the vector orbit; O(|orbit| · n) per call. + /// + /// Kept as the brute-force oracle that [`Bsgs::canonicalize`] is tested + /// against; the tool itself always uses the pruned walk. + #[cfg(test)] + pub(crate) fn canonicalize_naive(&self, state: &[usize], param_offset: usize) -> Vec { + use std::collections::HashSet; + + if self.chain.is_empty() { + return state.to_vec(); + } + + let params: Vec = state[param_offset..].to_vec(); + let n = self.n; + + // Collect all generators as DensePerms acting on the param slice. + let generators = self.all_generators(); + + let mut visited: HashSet> = HashSet::new(); + let mut queue: VecDeque> = VecDeque::new(); + let mut min_params = params.clone(); + + visited.insert(params.clone()); + queue.push_back(params); + + while let Some(current) = queue.pop_front() { + if current < min_params { + min_params = current.clone(); + } + + for g in &generators { + debug_assert_eq!(g.len(), n); + let next = g.apply_to_vec(¤t); + if visited.insert(next.clone()) { + queue.push_back(next); + } + } + } + + let _ = n; // suppress unused warning when debug_assert is off + let mut result = state[..param_offset].to_vec(); + result.extend_from_slice(&min_params); + result + } + + /// Returns the lex-min image of `state` under the group. + /// + /// `param_offset` is the index of the first parameter in `state`; positions + /// `0..param_offset` (the equation index) are never permuted. The parameter + /// block `state[param_offset..]` must have exactly [`Bsgs::n`] entries, so + /// callers must not pass states that lack a full parameter block. + /// + /// # Details + /// + /// Pruned transversal walk over the stabilizer chain. Writing the image as + /// `w[j] = params[σ(j)]` for `σ ∈ G`, each `σ` factorises uniquely as + /// `σ = u_1 ∘ … ∘ u_k` with `u_i ∈ U_i`. Every factor beyond level `i` lies in + /// the stabilizer of `β_i`, so `σ(β_i) = (u_1 ∘ … ∘ u_i)(β_i)` is already fixed + /// once the level-`i` choice is made. That is what makes the greedy sound: at + /// level `i` we can keep only the prefixes minimising `w[β_i]` and discard the + /// rest, because no later choice can change that entry. + /// + /// Deciding positions in base order equals deciding them in index order + /// because the base is strictly increasing and any position skipped by the base + /// is fixed by the whole group at that level, hence contributes the same value + /// to every candidate. + /// + /// Cost: Σ |U_i| transversal scans and one composition per surviving candidate, + /// versus |orbit|·n for the naive BFS. + pub(crate) fn canonicalize(&self, state: &[usize], param_offset: usize) -> Vec { + let mut out = Vec::with_capacity(state.len()); + self.canonicalize_into(state, param_offset, &mut CanonicalizeContext::default(), &mut out); + out + } + + /// [`Bsgs::canonicalize`] writing into `out` and borrowing `scratch`, so that + /// a call costs no allocation. Both are cleared first; reuse them across calls. + pub(crate) fn canonicalize_into( + &self, + state: &[usize], + param_offset: usize, + scratch: &mut CanonicalizeContext, + out: &mut Vec, + ) { + out.clear(); + if self.chain.is_empty() { + out.extend_from_slice(state); + return; + } + + let n = self.n; + let params: &[usize] = &state[param_offset..]; + debug_assert_eq!( + params.len(), + n, + "canonicalize requires a full parameter block; sinks and subformula states have none" + ); + debug_assert!( + self.chain.windows(2).all(|w| w[0].base_point < w[1].base_point), + "the greedy relies on a strictly increasing base" + ); + + let CanonicalizeContext { + current, + next, + survivors, + best, + } = scratch; + + // Candidate prefixes `u_1 ∘ … ∘ u_i` that all achieve the lex-min entries + // at base points β_1, …, β_i decided so far, as concatenated image vectors. + current.clear(); + current.extend(0..n); // the identity, the empty product + + for level in &self.chain { + // Score every extension without building it. Extending on the right + // gives `(prefix ∘ u)(β_i) = prefix(u(β_i))`, and `u(β_i)` is exactly + // the orbit point the transversal entry is paired with, so the image + // entry `w[β_i] = params[(prefix ∘ u)(β_i)]` is one lookup away. + survivors.clear(); + let mut best_value = usize::MAX; + for (p, prefix) in current.chunks_exact(n).enumerate() { + for (u_idx, &(orbit_point, _)) in level.transversal.iter().enumerate() { + let value = params[prefix[orbit_point]]; + + if value < best_value { + best_value = value; + survivors.clear(); + survivors.push((p, u_idx)); + } else if value == best_value { + survivors.push((p, u_idx)); + } + } + } + + // Only now compose, and only for the extensions that survived. + next.clear(); + for &(p, u_idx) in survivors.iter() { + let prefix = ¤t[p * n..(p + 1) * n]; + let u = &level.transversal[u_idx].1; + next.extend(u.images.iter().map(|&x| prefix[x])); + } + + std::mem::swap(current, next); + } + + // The surviving candidates agree on every base point but may still differ + // on positions the base does not cover, so compare the full images + // `w[i] = params[σ(i)]`, which `cmp` does without building them. + best.clear(); + for sigma in current.chunks_exact(n) { + if best.is_empty() || sigma.iter().map(|&i| params[i]).cmp(best.iter().copied()).is_lt() { + best.clear(); + best.extend(sigma.iter().map(|&i| params[i])); + } + } + + out.extend_from_slice(&state[..param_offset]); + out.extend_from_slice(if best.is_empty() { params } else { best }); + } + + /// Flat list of all generators appearing across all levels. + #[cfg(test)] + fn all_generators(&self) -> Vec { + use std::collections::HashSet; + + let mut gens: HashSet = HashSet::new(); + for level in &self.chain { + for (_, u) in &level.transversal { + gens.insert(u.clone()); + gens.insert(u.inverse()); + } + } + gens.into_iter().collect() + } +} + +/// GAP script template: renders generators in GAP cycle notation and calls +/// `ExplicitBSGS`, then prints the result to stdout. +fn build_gap_bsgs_script(gens: &[Permutation], n: usize) -> String { + let gen_strs: Vec = gens.iter().map(|p| permutation_to_gap_cycles(p, n)).collect(); + let gens_joined = gen_strs.join(", "); + + // Build a comma-separated base list "1,2,...,n". + let base: String = (1..=n).map(|i| i.to_string()).collect::>().join(","); + + format!( + r#" +ExplicitBSGS := function(G, chain) + local b, level, rest; + b := chain.orbit[1]; + level := rec( + basepoint := b, + transversal := List(chain.orbit, x -> RepresentativeAction(G, b, x)) + ); + if IsBound(chain.stabilizer) and Length(chain.stabilizer.generators) > 0 then + rest := ExplicitBSGS(Stabilizer(G, b), chain.stabilizer); + return Concatenation([level], rest); + else + return [level]; + fi; +end; + +G := Group({gens}); +base := [{base}]; +chain := StabChain(G, base); +bsgs := ExplicitBSGS(G, chain); +Print("BSGS-BEGIN\n"); +Print(bsgs, "\n"); +Print("BSGS-END\n"); +quit; +"#, + gens = gens_joined, + base = base, + ) +} + +/// Render a `Permutation` as a GAP cycle string using 1-based indices. +/// The identity is rendered as `()`. +fn permutation_to_gap_cycles(perm: &Permutation, n: usize) -> String { + let dense = DensePermutation::from_permutation(perm, n); + let mut visited = vec![false; n]; + let mut result = String::new(); + + for start in 0..n { + if visited[start] || dense.apply(start) == start { + visited[start] = true; + continue; + } + + result.push('('); + let mut current = start; + let mut first = true; + + loop { + if !first { + result.push(','); + } + + first = false; + result.push_str(&(current + 1).to_string()); // 1-based + visited[current] = true; + current = dense.apply(current); + + if current == start { + break; + } + } + result.push(')'); + } + + if result.is_empty() { + result.push_str("()"); + } + result +} + +/// Invoke GAP to compute the explicit BSGS for `gens` acting on `0..n`. +pub(crate) fn bsgs_from_gap(gens: &[Permutation], n: usize, config: &GapConfig) -> Result { + let script = build_gap_bsgs_script(gens, n); + let stdout = run_gap(&script, config)?; + parse_gap_bsgs_output(&stdout, n) +} + +/// Parse the GAP output produced by `ExplicitBSGS`. +/// +/// GAP prints a list of records of the form: +/// `[ rec( basepoint := B, transversal := [ perm, ... ] ), ... ]` +/// +/// Points are 1-based in GAP; we convert to 0-based. +fn parse_gap_bsgs_output(stdout: &str, n: usize) -> Result { + let begin = stdout + .find("BSGS-BEGIN") + .ok_or_else(|| MercError::from("GAP BSGS output missing 'BSGS-BEGIN' sentinel"))?; + let after_begin = &stdout[begin + "BSGS-BEGIN".len()..]; + let end = after_begin + .find("BSGS-END") + .ok_or_else(|| MercError::from("GAP BSGS output missing 'BSGS-END' sentinel"))?; + let inner = after_begin[..end].trim(); + + // Flatten to a single string without newlines for easier parsing. + let flat: String = inner.chars().filter(|&c| c != '\n' && c != '\r').collect(); + + let chain = parse_bsgs_list(&flat, n)?; + Ok(Bsgs { n, chain }) +} + +/// Parse `[ rec(...), rec(...), ... ]` into a Vec of SchreierLevel. +fn parse_bsgs_list(s: &str, n: usize) -> Result, MercError> { + let s = s.trim(); + // Strip outer `[ ... ]` + let s = s + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or_else(|| MercError::from(format!("Expected outer list brackets in BSGS output, got: {s}")))? + .trim(); + + let records = split_top_level(s, ','); + let mut levels = Vec::new(); + for rec_str in records { + let rec_str = rec_str.trim(); + if rec_str.is_empty() { + continue; + } + levels.push(parse_schreier_level(rec_str, n)?); + } + Ok(levels) +} + +/// Parse one `rec( basepoint := B, transversal := [ perm, ... ] )`. +fn parse_schreier_level(s: &str, n: usize) -> Result { + let inner = s + .trim() + .strip_prefix("rec(") + .and_then(|s| s.strip_suffix(')')) + .ok_or_else(|| MercError::from(format!("Expected 'rec(...)' but got: {s}")))? + .trim(); + + // Split into fields at top-level commas. + let fields = split_top_level(inner, ','); + + let mut base_point: Option = None; + let mut transversal_str: Option = None; + + for field in &fields { + let field = field.trim(); + if let Some(rest) = field.strip_prefix("basepoint :=") { + let bp: usize = rest + .trim() + .parse::() + .map_err(|_| MercError::from(format!("Invalid basepoint: {rest}")))?; + base_point = Some(bp - 1); // convert to 0-based + } else if let Some(rest) = field.strip_prefix("transversal :=") { + transversal_str = Some(rest.trim().to_string()); + } + } + + let base_point = base_point.ok_or_else(|| MercError::from("Missing 'basepoint' in rec"))?; + let transversal_str = transversal_str.ok_or_else(|| MercError::from("Missing 'transversal' in rec"))?; + + let transversal = parse_transversal_list(&transversal_str, base_point, n)?; + Ok(SchreierLevel::new(base_point, transversal)) +} + +/// Parse `[ perm, perm, ... ]` where the i-th perm maps `base_point` to +/// `orbit[i]`; orbit points are determined by applying each perm to `base_point`. +fn parse_transversal_list(s: &str, base_point: usize, n: usize) -> Result, MercError> { + let s = s + .trim() + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .ok_or_else(|| MercError::from(format!("Expected list brackets in transversal, got: {s}")))? + .trim(); + + let perm_strs = split_top_level(s, ','); + let mut map = HashMap::new(); + + for perm_str in &perm_strs { + let perm_str = perm_str.trim(); + if perm_str.is_empty() { + continue; + } + let dense = parse_gap_perm(perm_str, n)?; + let orbit_point = dense.apply(base_point); + map.insert(orbit_point, dense); + } + + Ok(map) +} + +/// Parse a single GAP permutation: either `()` (identity) or cycle product `(a,b,...)(c,d,...)`. +/// Points are 1-based in GAP; converts to 0-based. +fn parse_gap_perm(s: &str, n: usize) -> Result { + let s = s.trim(); + if s == "()" { + return Ok(DensePermutation::identity(n)); + } + + let mut images: Vec = (0..n).collect(); + + // Parse each cycle `(a,b,c,...)`. + let mut pos = 0; + while pos < s.len() { + let Some(open) = s[pos..].find('(') else { + break; + }; + let open = pos + open; + let close = s[open..] + .find(')') + .ok_or_else(|| MercError::from(format!("Unclosed '(' in GAP permutation: {s}")))? + + open; + + let content = &s[open + 1..close]; + let pts: Vec = content + .split(',') + .map(|t| { + t.trim() + .parse::() + .map_err(|_| MercError::from(format!("Bad point '{t}' in cycle"))) + .and_then(|v| { + v.checked_sub(1) + .ok_or_else(|| MercError::from("GAP point 0 is invalid (expected >= 1)")) + }) + }) + .collect::>()?; + + let len = pts.len(); + for i in 0..len { + images[pts[i]] = pts[(i + 1) % len]; + } + + pos = close + 1; + } + + Ok(DensePermutation { images }) +} + +/// Split `s` at top-level occurrences of `sep`, not entering `(` / `[` / `]` / `)`. +fn split_top_level(s: &str, sep: char) -> Vec { + let mut result = Vec::new(); + let mut depth = 0i32; + let mut current = String::new(); + + for ch in s.chars() { + match ch { + '(' | '[' => { + depth += 1; + current.push(ch); + } + ')' | ']' => { + depth -= 1; + current.push(ch); + } + c if c == sep && depth == 0 => { + result.push(current.trim().to_string()); + current = String::new(); + } + _ => current.push(ch), + } + } + if !current.trim().is_empty() { + result.push(current.trim().to_string()); + } + result +} + +/// Build a BSGS using the deterministic Schreier–Sims algorithm. +/// +/// Base selection: choose the first non-fixed point of any generator at each level. +fn bsgs_schreier_sims(gens: &[Permutation], n: usize) -> Result { + let dense_gens: Vec = gens.iter().map(|p| DensePermutation::from_permutation(p, n)).collect(); + let chain = schreier_sims_chain(&dense_gens, n); + Ok(Bsgs { n, chain }) +} + +fn schreier_sims_chain(gens: &[DensePermutation], n: usize) -> Vec { + if gens.is_empty() { + return vec![]; + } + + // Pick base point: first non-fixed point of any generator. + let base_point = (0..n).find(|&x| gens.iter().any(|g| g.apply(x) != x)).unwrap_or(0); + + // BFS to compute the orbit and transversal for `base_point`. + let transversal = compute_orbit_transversal(gens, base_point, n); + + // Compute Schreier generators for the stabilizer Stab(base_point). + let stab_gens = schreier_generators(gens, &transversal, base_point); + + let mut chain = vec![SchreierLevel::new(base_point, transversal)]; + + if !stab_gens.is_empty() { + let mut sub = schreier_sims_chain(&stab_gens, n); + chain.append(&mut sub); + } + + chain +} + +/// BFS orbit + transversal for `base_point` under `gens`. +/// `transversal[x]` = coset rep mapping `base_point` to `x`. +fn compute_orbit_transversal( + gens: &[DensePermutation], + base_point: usize, + n: usize, +) -> HashMap { + let _ = n; + let mut transversal: HashMap = HashMap::new(); + let mut queue: VecDeque = VecDeque::new(); + + transversal.insert(base_point, DensePermutation::identity(gens[0].len())); + queue.push_back(base_point); + + while let Some(x) = queue.pop_front() { + let rep_x = transversal[&x].clone(); + for g in gens { + let y = g.apply(x); + if let Entry::Vacant(entry) = transversal.entry(y) { + entry.insert(DensePermutation::compose(g, &rep_x)); + queue.push_back(y); + } + } + } + + transversal +} + +/// Compute Schreier generators for `Stab(base_point)`. +/// For each orbit point `x` and generator `s`, form `u_x · s · u_{s(x)}^{-1}`, +/// discarding the identity. +fn schreier_generators( + gens: &[DensePermutation], + transversal: &HashMap, + _base_point: usize, +) -> Vec { + let mut result: Vec = Vec::new(); + + for (&x, u_x) in transversal { + for s in gens { + let sx = s.apply(x); + let u_sx_inv = transversal[&sx].inverse(); + // schr = u_x · s · u_sx^{-1} + let schr = DensePermutation::compose(&u_sx_inv, &DensePermutation::compose(s, u_x)); + + // Keep only non-identity Schreier generators. + let id: Vec = (0..schr.len()).collect(); + if schr.images != id { + result.push(schr); + } + } + } + + // Deduplicate. + result.sort_unstable_by(|a, b| a.images.cmp(&b.images)); + result.dedup(); + result +} + +#[cfg(test)] +mod tests { + use std::sync::OnceLock; + + use itertools::Itertools; + use rand::RngExt; + use rand::rngs::StdRng; + use rand::seq::SliceRandom; + + use merc_utilities::random_test; + + use crate::bsgs::DensePermutation; + use crate::bsgs::bsgs_from_gap; + use crate::bsgs::bsgs_schreier_sims; + use crate::bsgs::parse_gap_perm; + use crate::bsgs::permutation_to_gap_cycles; + use crate::graph_symmetry::GapConfig; + use crate::graph_symmetry::run_gap; + use crate::permutation::Permutation; + + fn s3_generators() -> (Vec, usize) { + // S3 on {0,1,2}: generators (0 1) and (0 1 2). + let swap = Permutation::from_mapping(vec![(0, 1), (1, 0)]); + let rot = Permutation::from_mapping(vec![(0, 1), (1, 2), (2, 0)]); + (vec![swap, rot], 3) + } + + fn s4_generators() -> (Vec, usize) { + // S4 on {0,1,2,3}: a transposition and a 4-cycle. Order 24, and unlike + // the other two groups its stabilizer chain is three levels deep. + let swap = Permutation::from_cycle_notation("(0 1)").unwrap(); + let cycle = Permutation::from_cycle_notation("(0 1 2 3)").unwrap(); + (vec![swap, cycle], 4) + } + + fn alloc3_generators() -> (Vec, usize) { + // From the alloc3 INFO log: generators (4 6)(5 7) and (2 4)(3 5), 0-based. + let g1 = Permutation::from_cycle_notation("(4 6)(5 7)").unwrap(); + let g2 = Permutation::from_cycle_notation("(2 4)(3 5)").unwrap(); + (vec![g1, g2], 8) + } + + #[test] + fn dense_perm_compose_and_inverse() { + let (gens, n) = s3_generators(); + let swap = DensePermutation::from_permutation(&gens[0], n); + let rot = DensePermutation::from_permutation(&gens[1], n); + + // swap ∘ rot: first rot then swap + let composed = DensePermutation::compose(&swap, &rot); + // rot: 0→1→2→0; swap: 0↔1 + // 0 -rot-> 1 -swap-> 0 + // 1 -rot-> 2 -swap-> 2 + // 2 -rot-> 0 -swap-> 1 + assert_eq!(composed.images, vec![0, 2, 1]); + + let inv = swap.inverse(); + let id = DensePermutation::compose(&swap, &inv); + assert_eq!(id.images, vec![0, 1, 2]); + } + + #[test] + fn dense_perm_apply_to_vec() { + // swap (0 1) on vector [a, b, c] → [b, a, c] + let (gens, n) = s3_generators(); + let swap = DensePermutation::from_permutation(&gens[0], n); + let v = vec![10, 20, 30]; + // swap.apply(0)=1, swap.apply(1)=0, swap.apply(2)=2 + // result[1]=v[0]=10, result[0]=v[1]=20, result[2]=v[2]=30 → [20,10,30] + assert_eq!(swap.apply_to_vec(&v), vec![20, 10, 30]); + } + + #[test] + fn schreier_sims_order_s3() { + let (gens, n) = s3_generators(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + assert_eq!(bsgs.order(), 6); // |S3| = 6 + } + + #[test] + fn schreier_sims_order_alloc3() { + let (gens, n) = alloc3_generators(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + assert_eq!(bsgs.order(), 6); + } + + #[test] + fn canonicalize_stage_a_is_idempotent() { + let (gens, n) = s3_generators(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + + for v in [[2usize, 0, 1], [1, 2, 0], [0, 2, 1]] { + let state: Vec = std::iter::once(0).chain(v).collect(); // eq_idx=0 + let canon = bsgs.canonicalize_naive(&state, 1); + let canon2 = bsgs.canonicalize_naive(&canon, 1); + assert_eq!(canon, canon2, "Stage A not idempotent on {state:?}"); + } + } + + #[test] + fn canonicalize_stage_b_agrees_with_stage_a() { + let (gens, n) = s3_generators(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + + // Test all 6 permutations of (10, 20, 30). + let base = [10usize, 20, 30]; + let perms = [[0, 1, 2], [0, 2, 1], [1, 0, 2], [1, 2, 0], [2, 0, 1], [2, 1, 0]]; + for perm in perms { + let params: Vec = perm.iter().map(|&i| base[i]).collect(); + let state: Vec = std::iter::once(99).chain(params).collect(); + let a = bsgs.canonicalize_naive(&state, 1); + let b = bsgs.canonicalize(&state, 1); + assert_eq!(a, b, "Stage A and B disagree on {state:?}"); + } + } + + /// Exhaustive agreement on vectors with *repeated* values. + /// + /// Pairwise-distinct vectors are the one class where a wrong coset + /// factorisation still happens to land on the lex-min representative, so they + /// hide exactly the bug this checks for. + #[test] + fn canonicalize_agrees_with_naive_on_repeated_values() { + for (gens, n) in [s3_generators(), alloc3_generators()] { + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + + // All binary vectors of length n: every value repeats. + for mask in 0..(1u32 << n) { + let params: Vec = (0..n).map(|i| ((mask >> i) & 1) as usize).collect(); + let state: Vec = std::iter::once(7).chain(params).collect(); + + let naive = bsgs.canonicalize_naive(&state, 1); + let pruned = bsgs.canonicalize(&state, 1); + assert_eq!(naive, pruned, "disagreement on {state:?} (n = {n})"); + } + } + } + + /// Canonicalization must be constant on orbits: every image of a state has to + /// canonicalize to the same representative, which is what makes the quotient + /// sound. + #[test] + fn canonicalize_is_constant_on_orbits() { + let (gens, n) = alloc3_generators(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + let generators = bsgs.all_generators(); + + for mask in 0..(1u32 << n) { + let params: Vec = (0..n).map(|i| ((mask >> i) & 1) as usize).collect(); + let state: Vec = std::iter::once(0).chain(params.iter().copied()).collect(); + let expected = bsgs.canonicalize(&state, 1); + + for g in &generators { + let image: Vec = std::iter::once(0).chain(g.apply_to_vec(¶ms)).collect(); + assert_eq!( + bsgs.canonicalize(&image, 1), + expected, + "state {state:?} and its image {image:?} have different representatives" + ); + } + } + } + + #[test] + fn canonicalize_is_idempotent() { + let (gens, n) = alloc3_generators(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + + let state: Vec = (0..9).collect(); // eq_idx=0, params=1..8 + let canon = bsgs.canonicalize(&state, 1); + let canon2 = bsgs.canonicalize(&canon, 1); + assert_eq!(canon, canon2); + } + + #[test] + fn equation_index_untouched_by_canonicalize() { + let (gens, n) = s3_generators(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + + let state = vec![42usize, 2, 0, 1]; + let canon = bsgs.canonicalize(&state, 1); + assert_eq!(canon[0], 42, "equation index must not be permuted"); + } + + /// Returns `true` when a GAP that can run the `ExplicitBSGS` script is on the + /// path. Cached so the probe runs at most once per test process. + /// + /// Unlike graph symmetry detection this needs no Digraphs package — only + /// core GAP's `StabChain` — so it probes for plain GAP. + fn gap_available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + duct::cmd("gap", ["-q", "-A", "-r", "--quitonbreak"]) + .stdin_bytes("QUIT_GAP(0);;") + .stdout_null() + .stderr_null() + .unchecked() + .run() + .map(|o| o.status.success()) + .unwrap_or(false) + }) + } + + /// `Bsgs::from_generators` silently falls back to the local Schreier–Sims + /// when GAP fails, so every other test in this module exercises the fallback + /// even on a machine that has GAP. This one calls [`bsgs_from_gap`] directly, + /// which is the path the tool actually takes, and checks that GAP's chain + /// describes the same group and canonicalizes identically. + #[test] + fn bsgs_from_gap_agrees_with_schreier_sims() { + if !gap_available() { + return; + } + + for (gens, n) in [s3_generators(), alloc3_generators()] { + let gap = bsgs_from_gap(&gens, n, &GapConfig::default()).expect("GAP is available"); + let local = bsgs_schreier_sims(&gens, n).unwrap(); + + assert_eq!(gap.order(), local.order(), "GAP and Schreier–Sims disagree on |G|"); + assert_eq!(gap.n, n); + + // A different base gives a different chain, so the chains themselves + // need not match; what has to match is the representative each one + // picks. Binary vectors cover the repeated values that a wrong coset + // factorisation gets wrong. + for mask in 0..(1u32 << n) { + let params: Vec = (0..n).map(|i| ((mask >> i) & 1) as usize).collect(); + let state: Vec = std::iter::once(3).chain(params).collect(); + assert_eq!( + gap.canonicalize(&state, 1), + local.canonicalize(&state, 1), + "GAP and Schreier–Sims canonicalize {state:?} differently (n = {n})" + ); + } + } + } + + /// The alloc3 generators generate a group of order 6, as GAP itself reports + /// for that PBES (`|Sym(pbes)| = 6`). + #[test] + fn bsgs_from_gap_order_alloc3() { + if !gap_available() { + return; + } + + let (gens, n) = alloc3_generators(); + let bsgs = bsgs_from_gap(&gens, n, &GapConfig::default()).expect("GAP is available"); + assert_eq!(bsgs.order(), 6); + } + + /// Every vector in `{0, 1, 2}^n`, least-significant digit first. + /// + /// Ternary rather than binary because it covers both patterns that matter: + /// entries that repeat, where a wrong coset factorisation shows up, and + /// entries that differ, where the tie-breaking between levels does. + fn ternary_vectors(n: usize) -> Vec> { + (0..3usize.pow(n as u32)) + .map(|code| { + let mut code = code; + (0..n) + .map(|_| { + let digit = code % 3; + code /= 3; + digit + }) + .collect() + }) + .collect() + } + + /// Lex-min of each state under the group generated by `gens`, as computed by + /// GAP's `Minimum(List(Elements(G), g -> Permuted(s, g)))`. + /// + /// GAP enumerates the whole group and minimises directly, so it shares no + /// idea with the stabilizer chain it is used to check — that is the point. + /// It is also why callers must keep the group small enough to enumerate. + fn gap_lex_min(gens: &[Permutation], n: usize, states: &[Vec]) -> Vec> { + debug_assert!( + states.iter().all(|s| s.len() == n), + "each state must be a full parameter block of degree n" + ); + + let gens_joined = gens.iter().map(|p| permutation_to_gap_cycles(p, n)).format(", "); + let states_joined = states.iter().map(|s| format!("[{}]", s.iter().format(","))).format(",\n "); + + // `Permuted(s, g)[g(i)] = s[i]`, matching `DensePermutation::apply_to_vec`. + let script = format!( + r#" +G := Group([{gens_joined}]);; +elems := Elements(G);; +states := [ + {states_joined} +];; +Print("LEXMIN-BEGIN\n"); +for s in states do + Print(Minimum(List(elems, g -> Permuted(s, g))), "\n"); +od; +Print("LEXMIN-END\n"); +quit; +"# + ); + + let stdout = run_gap(&script, &GapConfig::default()).expect("GAP is available"); + parse_gap_lex_min_output(&stdout, states.len(), n) + } + + /// Parse the bracketed lists GAP prints between the sentinels. All whitespace + /// is dropped first, so GAP wrapping a long list over several lines is fine. + fn parse_gap_lex_min_output(stdout: &str, expected: usize, n: usize) -> Vec> { + let begin = stdout + .find("LEXMIN-BEGIN") + .expect("GAP lex-min output missing 'LEXMIN-BEGIN' sentinel"); + let after_begin = &stdout[begin + "LEXMIN-BEGIN".len()..]; + let end = after_begin + .find("LEXMIN-END") + .expect("GAP lex-min output missing 'LEXMIN-END' sentinel"); + + let flat: String = after_begin[..end].chars().filter(|c| !c.is_whitespace()).collect(); + + let minima: Vec> = flat + .split_terminator(']') + .map(|chunk| { + let inner = chunk + .strip_prefix('[') + .unwrap_or_else(|| panic!("expected a bracketed list from GAP, got: {chunk}")); + inner + .split(',') + .map(|entry| { + entry + .parse::() + .unwrap_or_else(|_| panic!("expected a number from GAP, got: {entry}")) + }) + .collect() + }) + .collect(); + + assert_eq!(minima.len(), expected, "GAP returned the wrong number of minima"); + assert!( + minima.iter().all(|m| m.len() == n), + "GAP returned a minimum of the wrong degree" + ); + minima + } + + /// The pruned walk claims to return the lex-least image, so check that claim + /// against GAP rather than only against the in-crate BFS oracle. + #[test] + fn canonicalize_matches_gap_lex_min() { + if !gap_available() { + return; + } + + for (gens, n) in [s3_generators(), s4_generators(), alloc3_generators()] { + let states = ternary_vectors(n); + let expected = gap_lex_min(&gens, n, &states); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + + for (params, expected) in states.iter().zip(&expected) { + let state: Vec = std::iter::once(7).chain(params.iter().copied()).collect(); + let canon = bsgs.canonicalize(&state, 1); + assert_eq!( + &canon[1..], + expected.as_slice(), + "canonicalize and GAP disagree on {params:?} (n = {n})" + ); + } + } + } + + /// Same check for the chain GAP builds, which is the one the tool actually + /// walks — a different base from Schreier–Sims, so a separate code path + /// through [`Bsgs::canonicalize`]. + #[test] + fn gap_chain_canonicalize_matches_gap_lex_min() { + if !gap_available() { + return; + } + + for (gens, n) in [s3_generators(), s4_generators(), alloc3_generators()] { + let states = ternary_vectors(n); + let expected = gap_lex_min(&gens, n, &states); + let bsgs = bsgs_from_gap(&gens, n, &GapConfig::default()).expect("GAP is available"); + + for (params, expected) in states.iter().zip(&expected) { + let state: Vec = std::iter::once(7).chain(params.iter().copied()).collect(); + let canon = bsgs.canonicalize(&state, 1); + assert_eq!( + &canon[1..], + expected.as_slice(), + "the GAP-built chain and GAP disagree on {params:?} (n = {n})" + ); + } + } + } + + /// A uniformly random permutation of degree `n`, never the identity: GAP's + /// `StabChain` has no level to report for the trivial group, so an all-identity + /// generator set would test the error path rather than the canonicalization. + fn random_permutation(rng: &mut StdRng, n: usize) -> Permutation { + debug_assert!(n >= 2, "no non-identity permutation exists below degree 2"); + + loop { + let mut image: Vec = (0..n).collect(); + image.shuffle(rng); + + let mapping: Vec<(usize, usize)> = (0..n).zip(image).filter(|(x, y)| x != y).collect(); + if !mapping.is_empty() { + return Permutation::from_mapping(mapping); + } + } + } + + /// Random states over a value range far smaller than the degree, so that + /// entries mostly repeat — pairwise-distinct entries let a wrong coset + /// factorisation still land on the lex-min representative, which is exactly + /// the case that hides the bug. + fn random_states(rng: &mut StdRng, n: usize, count: usize) -> Vec> { + let mut states = Vec::with_capacity(count); + for _ in 0..count { + states.push((0..n).map(|_| rng.random_range(0..3usize)).collect()); + } + states + } + + /// Random groups checked against GAP. + /// + /// The handwritten groups above were all taken from PBESs that had already + /// been looked at, so they share whatever blind spot led to picking them; + /// here the group and the values are drawn independently of the code. + /// + /// Both chains are checked, because the two disagree on the base — GAP is + /// asked for `[1..n]` while Schreier–Sims picks the first moved point — and + /// a random group is what makes the bases actually diverge. + #[test] + fn random_canonicalize_matches_gap_lex_min() { + if !gap_available() { + return; + } + + // GAP enumerates the whole group and each iteration spawns two GAP + // processes, so both the degree and the iteration count stay small. + random_test(10, |rng| { + let n = rng.random_range(3..=6usize); + let num_gens = rng.random_range(1..=3); + let gens: Vec = (0..num_gens).map(|_| random_permutation(rng, n)).collect(); + let states = random_states(rng, n, 128); + + let expected = gap_lex_min(&gens, n, &states); + let local = bsgs_schreier_sims(&gens, n).unwrap(); + let from_gap = bsgs_from_gap(&gens, n, &GapConfig::default()).expect("GAP is available"); + + assert_eq!( + local.order(), + from_gap.order(), + "the two chains describe different groups for {gens:?}" + ); + + for (params, expected) in states.iter().zip(&expected) { + let state: Vec = std::iter::once(7).chain(params.iter().copied()).collect(); + + for (source, bsgs) in [("Schreier–Sims", &local), ("GAP", &from_gap)] { + assert_eq!( + &bsgs.canonicalize(&state, 1)[1..], + expected.as_slice(), + "the {source} chain disagrees with GAP on {params:?} (n = {n}, generators {gens:?})" + ); + } + } + }); + } + + /// The same property against the in-crate BFS oracle. GAP is the stronger + /// check, but it costs a process per group; this one is cheap enough to + /// cover an order of magnitude more groups. + #[test] + fn random_canonicalize_matches_naive() { + random_test(200, |rng| { + let n = rng.random_range(3..=6usize); + let num_gens = rng.random_range(1..=3); + let gens: Vec = (0..num_gens).map(|_| random_permutation(rng, n)).collect(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + + for params in random_states(rng, n, 20) { + let state: Vec = std::iter::once(7).chain(params.iter().copied()).collect(); + assert_eq!( + bsgs.canonicalize(&state, 1), + bsgs.canonicalize_naive(&state, 1), + "canonicalize and the BFS oracle disagree on {params:?} (n = {n}, generators {gens:?})" + ); + } + }); + } + + /// Canonicalization has to be constant on orbits — that is what makes the + /// quotient sound — so every image of a random state must reach the same + /// representative as the state itself. + #[test] + fn random_canonicalize_is_constant_on_orbits() { + random_test(200, |rng| { + let n = rng.random_range(3..=6usize); + let num_gens = rng.random_range(1..=3); + let gens: Vec = (0..num_gens).map(|_| random_permutation(rng, n)).collect(); + let bsgs = bsgs_schreier_sims(&gens, n).unwrap(); + let generators = bsgs.all_generators(); + + for params in random_states(rng, n, 20) { + let state: Vec = std::iter::once(7).chain(params.iter().copied()).collect(); + let expected = bsgs.canonicalize(&state, 1); + + assert_eq!( + bsgs.canonicalize(&expected, 1), + expected, + "canonicalize is not idempotent on {params:?} (n = {n}, generators {gens:?})" + ); + + for g in &generators { + let image: Vec = std::iter::once(7).chain(g.apply_to_vec(¶ms)).collect(); + assert_eq!( + bsgs.canonicalize(&image, 1), + expected, + "{params:?} and its image {image:?} have different representatives \ + (n = {n}, generators {gens:?})" + ); + } + } + }); + } + + #[test] + fn permutation_to_gap_cycles_roundtrip() { + let p = Permutation::from_cycle_notation("(0 2)(1 3)").unwrap(); + let gap_str = permutation_to_gap_cycles(&p, 4); + // Should produce 1-based cycles + let parsed = parse_gap_perm(&gap_str, 4).unwrap(); + let dense = DensePermutation::from_permutation(&p, 4); + assert_eq!(parsed.images, dense.images); + } +} diff --git a/tools/mcrl2/pbes/src/clone_iterator.rs b/tools/mcrl2/crates/merc_pbes/src/clone_iterator.rs similarity index 100% rename from tools/mcrl2/pbes/src/clone_iterator.rs rename to tools/mcrl2/crates/merc_pbes/src/clone_iterator.rs diff --git a/tools/mcrl2/crates/merc_pbes/src/explore_common.rs b/tools/mcrl2/crates/merc_pbes/src/explore_common.rs new file mode 100644 index 000000000..9bca2294c --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/src/explore_common.rs @@ -0,0 +1,457 @@ +use std::cell::Cell; +use std::ops::Range; + +use itertools::Itertools; +use log::info; + +use mcrl2::DataVariable; +use mcrl2::Pbes; +use merc_explore::CacheLPS; +use merc_explore::LPS; +use merc_explore::Summand; +use merc_explore::configure_rayon_thread_pool; +use merc_explore::explore; +use merc_explore::explore_parallel; +use merc_io::TimeProgress; +use merc_lts::StateIndex; +use merc_utilities::MercError; +use merc_utilities::ShardedCounter; +use merc_utilities::Timing; +use merc_vpg::ParityGame; +use merc_vpg::ParityGameBuilder; +use merc_vpg::Player; +use merc_vpg::Priority; +use merc_vpg::VertexIndex; + +use merc_explore::ExplorationStrategy; + +/// Whether counter-example equations are excluded when unifying parameters. +/// +/// Part of [`UNIFY_RESET_PARAMETERS`]'s contract: every caller must pass the same +/// pair of flags. +pub const UNIFY_IGNORE_CE_EQUATIONS: bool = false; + +/// Whether a parameter that an equation does not declare is reset to a default +/// value rather than copied through. +/// +/// Symmetry detection and exploration must unify parameters with *identical* +/// flags. This one changes the right-hand sides (see mCRL2's +/// `unify_parameters_replace_function`), so detecting symmetries on one PBES and +/// applying them while exploring a differently unified one is unsound. +pub const UNIFY_RESET_PARAMETERS: bool = true; + +/// The parameter vector that symmetry generators index into. +/// +/// [`crate::graph_symmetry::graph_symmetries`] derives its generators from the +/// PBES after [`Pbes::unify_parameters`] with the flags above, and numbers the +/// parameter vertices of the detection graph in the order of the resulting +/// vector. Generator point `k` therefore means "entry `k` of this vector", and +/// an explorer may only be quotiented by those generators when it lays its state +/// vectors out by the same parameters. +pub fn symmetry_parameter_basis(pbes: &Pbes) -> Result, MercError> { + let pbes = symmetry_unified_pbes(pbes)?; + + let equations = pbes.equations(); + let first = equations + .first() + .ok_or_else(|| MercError::from("PBES has no equations"))?; + Ok(first.variable().parameters().iter().collect()) +} + +/// The PBES that symmetry detection and quotient exploration actually see: +/// `pbes` with every equation's parameter vector unified under the flags above. +/// +/// Exposed so that the same PBES the generators are numbered against can be +/// written out and inspected; deriving it here rather than at the call site is +/// what keeps it from drifting away from [`symmetry_parameter_basis`]. +pub fn symmetry_unified_pbes(pbes: &Pbes) -> Result { + let mut pbes = pbes.clone(); + pbes.unify_parameters(UNIFY_IGNORE_CE_EQUATIONS, UNIFY_RESET_PARAMETERS)?; + Ok(pbes) +} + +/// Returns an error unless `parameters`, the vector `backend` lays its state +/// vectors out by, is the `basis` the symmetry generators index into. +/// +/// A permutation is only a list of positions, so nothing about it detects being +/// applied to the wrong vector: the exploration would silently swap unrelated +/// values and quotient the game by a group that is not a symmetry of it. +pub fn check_parameter_basis( + basis: &[DataVariable], + parameters: &[DataVariable], + backend: &str, +) -> Result<(), MercError> { + if basis == parameters { + return Ok(()); + } + + Err(MercError::from(format!( + "the {backend} explorer does not lay its states out by the parameter vector that the \ + symmetry generators index into, so applying them would permute the wrong values:\n \ + generators: [{}]\n {backend}: [{}]", + basis.iter().format(", "), + parameters.iter().format(", "), + ))) +} + +/// An [`LPS`] whose state vectors may carry a block of permutable data parameters. +/// +/// Exploring a PBES into a parity game produces states of several shapes: a +/// propositional variable instantiation carries the parameter vector, while sinks +/// and subformula vertices carry a priority and an interned formula index instead. +/// A symmetry group acts on the parameters only, so a layer that permutes state +/// vectors has to be able to tell the shapes apart — permuting a subformula +/// vertex's payload silently corrupts it. +pub trait ParameterLayoutLPS: LPS { + /// Returns the positions of `state` holding data parameters, or `None` when + /// this state has no parameter block. + fn parameter_range(&self, state: &[Self::Value]) -> Option>; +} + +impl ParameterLayoutLPS for CacheLPS

{ + fn parameter_range(&self, state: &[Self::Value]) -> Option> { + self.inner().parameter_range(state) + } +} + +impl ParameterLayoutLPS for &P { + fn parameter_range(&self, state: &[Self::Value]) -> Option> { + (**self).parameter_range(state) + } +} + +/// What a parity-game vertex was created for. +/// +/// mCRL2's `pbesinst_structure_graph` draws the same distinction: `SG0` creates +/// one vertex per propositional variable instantiation — the number its verbose +/// output reports as "Generated N BES equations" — while `SG1` creates an extra +/// vertex for every nested subformula that is not itself an instantiation. Both +/// kinds are vertices of the structure graph, so the total vertex count of the +/// generated parity game exceeds the equation count. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PbesVertexKind { + /// A propositional variable instantiation, i.e. one BES equation. + Instantiation, + + /// A nested and/or subformula that needs a vertex of its own because a + /// parity-game vertex has a single owner, so a disjunction occurring under a + /// conjunction (or vice versa) cannot be merged into its parent. + Subformula, + + /// One of the two `true` / `false` sink vertices. + Sink, +} + +/// Owner, priority and provenance of a parity-game vertex, produced by +/// [`LPS::state_info`] of the PBES explorers. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct PbesVertex { + /// The player owning the vertex. + pub player: Player, + + /// The vertex priority. + pub priority: Priority, + + /// What the vertex was created for. + pub kind: PbesVertexKind, +} + +impl PbesVertex { + pub fn new(player: Player, priority: Priority, kind: PbesVertexKind) -> Self { + PbesVertex { player, priority, kind } + } + + /// Shorthand for a vertex standing for a propositional variable instantiation. + pub fn instantiation(player: Player, priority: Priority) -> Self { + PbesVertex::new(player, priority, PbesVertexKind::Instantiation) + } +} + +/// Tally of the generated parity-game vertices, broken down by [`PbesVertexKind`]. +#[derive(Clone, Copy, Default)] +pub struct VertexCounts { + /// Vertices for propositional variable instantiations (BES equations). + instantiations: usize, + + /// Vertices for nested subformulas. + subformulas: usize, + + /// The `true` / `false` sink vertices, at most two. + sinks: usize, +} + +impl VertexCounts { + pub fn new(instantiations: usize, subformulas: usize, sinks: usize) -> Self { + VertexCounts { + instantiations, + subformulas, + sinks, + } + } + + /// Returns these counts with `kind` added. + fn with(mut self, kind: PbesVertexKind) -> Self { + match kind { + PbesVertexKind::Instantiation => self.instantiations += 1, + PbesVertexKind::Subformula => self.subformulas += 1, + PbesVertexKind::Sink => self.sinks += 1, + } + self + } + + /// Total number of vertices, i.e. the size of the structure graph. + fn total(&self) -> usize { + self.instantiations + self.subformulas + self.sinks + } +} + +/// Logs the final vertex breakdown, reporting the instantiation count (directly +/// comparable to mCRL2's "Generated N BES equations") separately from the +/// structure-graph vertices that surround it. +fn report_counts(counts: VertexCounts, edges: usize) { + info!( + "Exploration complete: {} BES equations, {} subformula vertices, {} sinks ({} vertices, {edges} edges)", + counts.instantiations, + counts.subformulas, + counts.sinks, + counts.total(), + ); +} + +/// Periodic progress reporter for PBES exploration. +pub fn bes_progress() -> TimeProgress<(VertexCounts, usize)> { + TimeProgress::new( + |(counts, edges): (VertexCounts, usize)| { + info!( + "Explored {} BES equations, {} vertices, {edges} edges...", + counts.instantiations, + counts.total(), + ); + }, + 1, + ) +} + +/// Builds a [`ParityGame`] by exploring any LPS that produces unit labels and +/// [`PbesVertex`] state info (i.e. a parity game vertex description). +pub fn explore_pbes_impl(lps: &M, strategy: ExplorationStrategy, timing: &Timing) -> Result +where + M: LPS, +{ + let mut builder = ParityGameBuilder::new(VertexIndex::new(0)); + + let progress = bes_progress(); + let counts = Cell::new(VertexCounts::default()); + let edges = Cell::new(0usize); + + let _initial = explore( + lps, + strategy, + timing, + &mut builder, + |b: &mut ParityGameBuilder, state: StateIndex, info: &PbesVertex| { + counts.set(counts.get().with(info.kind)); + b.add_vertex(VertexIndex::new(state.value()), info.player, info.priority); + Ok(()) + }, + |b: &mut ParityGameBuilder, from: StateIndex, _label: &(), to: StateIndex| { + edges.set(edges.get() + 1); + progress.print((counts.get(), edges.get())); + b.add_edge(VertexIndex::new(from.value()), VertexIndex::new(to.value())); + Ok(()) + }, + )?; + report_counts(counts.get(), edges.get()); + + Ok(builder.finish(true, true)) +} + +/// Per-worker output partition for parallel parity-game exploration. +#[derive(Default)] +pub struct PbesPartition { + /// Vertices discovered by this worker, with their owner, priority and kind. + pub vertices: Vec<(VertexIndex, PbesVertex)>, + + /// Edges discovered by this worker, as `(source, target)` pairs. + pub edges: Vec<(VertexIndex, VertexIndex)>, +} + +/// Builds a [`ParityGame`] by exploring any sync-safe LPS in parallel. +pub fn explore_pbes_parallel_impl( + lps: &M, + threads: usize, + pinned: bool, + timing: &Timing, +) -> Result +where + M: LPS + Sync, + ::Context: Send, +{ + let pool = configure_rayon_thread_pool(threads, pinned)?; + let instantiations = ShardedCounter::new(); + let subformulas = ShardedCounter::new(); + let sinks = ShardedCounter::new(); + let transitions = ShardedCounter::new(); + let progress = bes_progress(); + + let (_initial, partitions) = timing.measure("explore", || { + pool.install(|| { + explore_parallel( + lps, + PbesPartition::default, + |partition: &mut PbesPartition, state: StateIndex, info: &PbesVertex| { + partition.vertices.push((VertexIndex::new(state.value()), *info)); + match info.kind { + PbesVertexKind::Instantiation => instantiations.increment(), + PbesVertexKind::Subformula => subformulas.increment(), + PbesVertexKind::Sink => sinks.increment(), + } + Ok(()) + }, + |partition: &mut PbesPartition, from: StateIndex, _label: &(), to: StateIndex| { + partition + .edges + .push((VertexIndex::new(from.value()), VertexIndex::new(to.value()))); + if progress.is_due() { + let counts = VertexCounts::new( + instantiations.get() as usize, + subformulas.get() as usize, + sinks.get() as usize, + ); + progress.print((counts, transitions.get() as usize)); + } + transitions.increment(); + Ok(()) + }, + ) + }) + })?; + + let counts = partitions + .iter() + .flat_map(|p| p.vertices.iter()) + .fold(VertexCounts::default(), |counts, (_, vertex)| counts.with(vertex.kind)); + let total_edges: usize = partitions.iter().map(|p| p.edges.len()).sum(); + report_counts(counts, total_edges); + + let mut builder = ParityGameBuilder::new(VertexIndex::new(0)); + for partition in &partitions { + for &(index, vertex) in &partition.vertices { + builder.add_vertex(index, vertex.player, vertex.priority); + } + } + for partition in &partitions { + for &(from, to) in &partition.edges { + builder.add_edge(from, to); + } + } + Ok(builder.finish(true, true)) +} + +/// Computes a priority for each equation for a **max** parity game. +/// +/// `is_mu[i]` is `true` when equation `i` is a least fixpoint (μ), `false` for ν. +/// Equations must be in declaration order (outermost first). +/// +/// Algorithm: +/// 1. Assign each equation an *alternation depth* (incremented on every μ ↔ ν switch). +/// 2. Reverse so outermost (depth 0) → highest priority (max_depth). +/// 3. Shift all priorities by 1 when the outermost equation's parity does not +/// match its fixpoint type (ν → even, μ → odd). +pub fn compute_priorities(is_mu: &[bool]) -> Vec { + if is_mu.is_empty() { + return Vec::new(); + } + + let mut depths = vec![0usize; is_mu.len()]; + let mut current_depth = 0usize; + let mut prev_is_mu = is_mu[0]; + + for (i, &mu) in is_mu.iter().enumerate() { + if i > 0 && mu != prev_is_mu { + current_depth += 1; + } + depths[i] = current_depth; + prev_is_mu = mu; + } + + let max_depth = *depths.last().unwrap(); + let mut priorities: Vec = depths.iter().map(|&d| max_depth - d).collect(); + + let first_is_mu = is_mu[0]; + if first_is_mu == priorities[0].is_multiple_of(2) { + for p in &mut priorities { + *p += 1; + } + } + + debug_assert!( + priorities + .iter() + .zip(is_mu.iter()) + .all(|(p, &mu)| p.is_multiple_of(2) != mu), + "Max parity game invariant violated: ν must have even priority and μ must have odd priority" + ); + + priorities +} + +#[cfg(test)] +mod tests { + use mcrl2::Pbes; + + use super::check_parameter_basis; + use super::symmetry_parameter_basis; + + /// The basis merges the parameters of every equation, and is the same vector + /// each time it is asked for within one process. + /// + /// The *order* is deliberately not asserted: `unify_parameters` derives it + /// from term addresses, so it varies between processes (the same PBES yields + /// `[m, n]` in one run and `[n, m]` in another). That is what makes the + /// per-run [`check_parameter_basis`] necessary rather than a length check, + /// and why a `--quotient` generator index only means something within a run. + #[test] + #[cfg_attr(miri, ignore)] + fn parameter_basis_merges_all_equations_and_is_stable() { + let pbes = Pbes::from_text( + "pbes nu X(m: Nat) = Y(m) && X(m); + mu Y(n: Nat) = X(n) || Y(n); + init X(0);", + ) + .unwrap(); + + let basis = symmetry_parameter_basis(&pbes).unwrap(); + let mut names: Vec = basis.iter().map(|v| v.to_string()).collect(); + names.sort(); + assert_eq!(names, ["m: Nat", "n: Nat"]); + + assert_eq!( + basis, + symmetry_parameter_basis(&pbes).unwrap(), + "the basis must not change between calls, or the generators would index into \ + a different vector than the one they were checked against" + ); + } + + #[test] + #[cfg_attr(miri, ignore)] + fn parameter_basis_accepts_itself_and_rejects_a_different_vector() { + let pbes = Pbes::from_text("pbes nu X(m: Nat, n: Nat) = X(n, m);\ninit X(0, 1);").unwrap(); + let basis = symmetry_parameter_basis(&pbes).unwrap(); + assert_eq!(basis.len(), 2); + + check_parameter_basis(&basis, &basis, "test").expect("a vector must be its own basis"); + + // Reordered: same parameters, but position k now means a different one, + // which is exactly what makes a permutation of positions meaningless. + let swapped: Vec<_> = basis.iter().rev().cloned().collect(); + let error = check_parameter_basis(&basis, &swapped, "test") + .expect_err("a reordered vector is not the basis") + .to_string(); + assert!(error.contains("test"), "the error must name the backend: {error}"); + + // Truncated, the case a bare length check would also catch. + assert!(check_parameter_basis(&basis, &basis[..1], "test").is_err()); + } +} diff --git a/tools/mcrl2/crates/merc_pbes/src/explore_pbes.rs b/tools/mcrl2/crates/merc_pbes/src/explore_pbes.rs new file mode 100644 index 000000000..9225aa121 --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/src/explore_pbes.rs @@ -0,0 +1,849 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::ops::Range; +use std::sync::Arc; + +use log::debug; +use mcrl2::_aterm; +use mcrl2::ATermStringRef; +use mcrl2::DataExpression; +use mcrl2::DataExpressionRef; +use mcrl2::DataSpecification; +use mcrl2::DataVariable; +use mcrl2::Pbes; +use mcrl2::PbesConnective; +use mcrl2::PbesExpression; +use mcrl2::PbesExpressionRef; +use mcrl2::PbesExpressionVisitor; +use mcrl2::PbesFlattenIter; +use mcrl2::PbesFlattenStack; +use mcrl2::PbesPropositionalVariableInstantiationRef; +use mcrl2::PbesRewriteContext; +use mcrl2::Protected; +use mcrl2::is_pbes_and; +use mcrl2::is_pbes_false; +use mcrl2::is_pbes_or; +use mcrl2::is_pbes_propositional_variable_instantiation; +use mcrl2::is_pbes_true; +use mcrl2::is_variable; +use mcrl2::variable_occurrences_data_expression; +use merc_explore::CacheLPS; +use merc_explore::CachingStrategy; +use merc_explore::ExplorationStrategy; +use merc_explore::LPS; +use merc_explore::OwnedStateEffect; +use merc_explore::StateEffect; +use merc_explore::Summand; +use merc_unsafety::ConcurrentIndexedSet; +use merc_utilities::MercError; +use merc_utilities::Timing; +use merc_vpg::ParityGame; +use merc_vpg::Player; +use merc_vpg::Priority; + +use crate::explore_common::ParameterLayoutLPS; +use crate::explore_common::PbesVertex; +use crate::explore_common::PbesVertexKind; +use crate::explore_common::UNIFY_IGNORE_CE_EQUATIONS; +use crate::explore_common::UNIFY_RESET_PARAMETERS; +use crate::explore_common::compute_priorities; +use crate::explore_common::explore_pbes_impl; +use crate::explore_common::explore_pbes_parallel_impl; + +/// Tag values occupy the top 4 bits of a state[0] word so they never collide +/// with equation indices (which are small) or with usize::MAX (the sequence +/// forest's empty-slot sentinel). +const TAG_MASK: usize = 0xF << (usize::BITS - 4); +/// Source state is a true-sink (self-loop, Even wins). +const TRUE_SINK: usize = 0x1 << (usize::BITS - 4); +/// Source state is a false-sink (self-loop, Odd wins). +const FALSE_SINK: usize = 0x2 << (usize::BITS - 4); +/// Source state is a subformula AND node; full state: `[AND_OP, subformula_idx]`. +const AND_OP: usize = 0x3 << (usize::BITS - 4); +/// Source state is a subformula OR node; full state: `[OR_OP, subformula_idx]`. +const OR_OP: usize = 0x4 << (usize::BITS - 4); + +/// The priority every subformula vertex is given. +/// +/// A subformula vertex is a syntactic artefact rather than a fixpoint, so it +/// must not influence which priority dominates a cycle. Giving it the neutral +/// minimum achieves that: [`compute_priorities`] only produces priorities `>= 0`, +/// so a subformula vertex is never the maximum on a cycle that also contains an +/// instantiation — and every cycle does, because a subformula vertex's +/// successors are strict subterms of its own formula (see [`emit_as_target`]), +/// which makes the subgraph of subformula vertices acyclic. +/// +/// This is what lets the same nested formula be *one* vertex no matter which +/// equations reach it, mirroring mCRL2's `SG1`, whose `insert_vertex(psi)` is +/// keyed on the formula alone and leaves the rank of such a vertex undefined. +const SUBFORMULA_PRIORITY: usize = 0; + +type ValueMapping = ConcurrentIndexedSet>; +type SubformulaMapping = ConcurrentIndexedSet>; + +/// Maps a propositional variable name to the index of the equation defining it. +/// +/// Keyed on the name term rather than on a `String`: names are maximally shared, +/// so a lookup is a pointer hash, where rendering the name costs two `String` +/// allocations per edge. +type NameMapping = HashMap, usize>; + +/// The key of a [`NameMapping`], for a name read off a live term. +/// +/// # Safety +/// +/// The name must be a subterm of a term that stays alive for as long as the +/// mapping is used. +unsafe fn name_key(name: ATermStringRef<'_>) -> ATermStringRef<'static> { + // SAFETY: the caller upholds that the name stays live. + unsafe { ATermStringRef::from_address(name.address()) } +} + +/// Parity-game LPS that directly explores a PBES without converting to SRF. +/// +/// Applies the `enumerate_quantifiers_rewriter` on-the-fly to instantiate each +/// equation's right-hand side, then walks the resulting PBES expression to find +/// immediate successor states. +/// +/// State layout: +/// - PVI state: `[eq_idx, intern(v0), …, intern(vn)]` — length `1 + num_params` +/// - TRUE sink: `[TRUE_SINK]` +/// - FALSE sink: `[FALSE_SINK]` +/// - Subformula AND: `[AND_OP, subformula_idx]` +/// - Subformula OR: `[OR_OP, subformula_idx]` +pub struct PbesLps { + /// The unified PBES; retained so the terms borrowed by the summands stay + /// alive, and read back by [`PbesLps::parameters`]. + pbes: Pbes, + + /// Data specification used to build each per-thread [`PbesContext`]. + data_spec: DataSpecification, + + /// Flat list of summands: one per equation, plus the sink and subformula summands. + summands: Vec, + + /// For each equation index, the indices into [`PbesLps::summands`] of the + /// summands belonging to that equation. A source state only explores the + /// summands of its current equation (`state[0]`). + equation_summands: Vec>, + + /// Indices into [`PbesLps::summands`] of the summands fired by a true sink state. + true_sink_summands: Vec, + + /// Indices into [`PbesLps::summands`] of the summands fired by a false sink state. + false_sink_summands: Vec, + + /// Indices into [`PbesLps::summands`] of the summands fired by a subformula node. + subformula_summands: Vec, + + /// The initial state vector. + initial_state: Vec, + + /// Cached data-parameter variables (length `num_params`). All equations share + /// the same parameter list after unification. + process_parameters: Vec<*const _aterm>, + + /// Number of data parameters shared by every equation. + num_params: usize, + + /// Number of equations in the PBES. + num_equations: usize, + + /// Maps a propositional variable name to its equation index. + name_to_eq: Arc, + + /// Interning table for enumerated parameter values, shared with the summands. + value_mapping: Protected, + + /// Interning table for nested (and/or) sub-formulas, shared with the summands. + subformula_mapping: Protected, +} + +// SAFETY: after construction, PbesLps is immutable except for the two +// ConcurrentIndexedSets (which are thread-safe) and the Pbes (read-only). +unsafe impl Sync for PbesLps {} + +/// Determines which state shape a [`PbesSummand`] fires on and how it computes +/// its successors. +enum PbesSummandKind { + /// Instantiates the right-hand side of an equation for the current parameter + /// values; `priority` is the parity-game priority of that equation. + Equation { + formula: mcrl2::PbesExpression, + priority: usize, + }, + + /// The true sink, which has itself as its only successor. + TrueSink, + + /// The false sink, which has itself as its only successor. + FalseSink, + + /// Expands an interned nested sub-formula into its operands. + Subformula, +} + +/// A single summand of a [`PbesLps`], pre-bound to the state shape it fires on. +pub struct PbesSummand { + /// The state shape this summand fires on and how successors are derived. + kind: PbesSummandKind, + + /// Handle to the enclosing LPS's value interning, used to intern enumerated + /// next-state values from any worker thread. + value_mapping: Arc, + + /// Handle to the enclosing LPS's sub-formula interning. + subformula_mapping: Arc, + + /// Maps a propositional variable name to its equation index. + name_to_eq: Arc, + + /// Number of data parameters shared by every equation. + num_params: usize, + + /// Positions of the source state read by this summand. + read_positions: Vec, + + /// How this summand's next states relate to its source state. + effect: OwnedStateEffect, +} + +/// The lookup tables needed to encode a PBES sub-formula as a parity-game state: equation +/// names to indices, plus the interning tables for parameter values and sub-formulas. +#[derive(Clone, Copy)] +struct TargetTables<'a> { + name_to_eq: &'a NameMapping, + value_mapping: &'a ValueMapping, + subformula_mapping: &'a SubformulaMapping, +} + +/// Per-thread enumeration context for a [`PbesLps`]. +pub struct PbesContext { + /// The worker's own quantifier-enumerating rewriter. + rewrite: PbesRewriteContext, + + /// Scratch buffer holding the parameter values of the source state. + parameter_values: Vec<*const _aterm>, + + /// Scratch buffer for the next state reported to the callback. + next_state_buf: Vec, + + /// Scratch worklist for walking the operands of an and/or chain. + chain_stack: PbesFlattenStack, + + /// The instantiated right-hand side of the last explored state. + psi: Option, + + /// The owner and priority of the last explored state, reported by [`LPS::state_info`]. + player_priority: Option<(Player, Priority)>, +} + +// SAFETY: PbesContext is owned by exactly one worker thread. The +// PbesRewriteContext wraps a per-worker C++ rewriter that no other thread +// touches. The raw term pointers in parameter_values are stable addresses +// into the global term pool and are only read, never written. +unsafe impl Send for PbesContext {} + +impl PbesLps { + pub fn new(mut pbes: Pbes) -> Result { + pbes.unify_parameters(UNIFY_IGNORE_CE_EQUATIONS, UNIFY_RESET_PARAMETERS)?; + + let equations = pbes.equations(); + let num_equations = equations.len(); + if num_equations == 0 { + return Err("PBES has no equations".into()); + } + + let num_params = equations[0].variable().parameters().len(); + let is_mu: Vec = equations.iter().map(|e| e.is_mu()).collect(); + let priorities = compute_priorities(&is_mu); + + // SAFETY: every name is a subterm of an equation of `pbes`, which this + // explorer retains for as long as the mapping is used. + let name_to_eq: NameMapping = equations + .iter() + .enumerate() + .map(|(i, eq)| (unsafe { name_key(eq.variable().name().copy()) }, i)) + .collect(); + let name_to_eq = Arc::new(name_to_eq); + + // Raw pointers to the unified parameter variables (all equations share + // the same list after unify_parameters). + let process_parameters: Vec<*const _aterm> = equations[0] + .variable() + .parameters() + .iter() + .map(|v: DataVariable| v.address()) + .collect(); + + let value_mapping = Protected::new(ValueMapping::new()); + let subformula_mapping = Protected::new(SubformulaMapping::new()); + let data_spec = pbes.data_specification(); + + // Building a rewriter normalises the data specification *in place*: it + // imports the system-defined sorts and appends their constructors, which + // reallocates vectors of aterms. + let rewriter = PbesRewriteContext::from_data_spec(&data_spec)?; + + // Rewrite the initial state before interning it. + let initial_expr = PbesExpression::from(pbes.initial_state()); + // SAFETY: `initial_expr` owns a protected term read from the live PBES. + let initial_rewritten = unsafe { rewriter.rewrite_formula(&initial_expr) }?; + if !is_pbes_propositional_variable_instantiation(&initial_rewritten.copy()) { + return Err(MercError::from(format!( + "The initial state does not rewrite to a propositional variable instantiation: {}", + initial_rewritten.copy() + ))); + } + let initial_pvi = PbesPropositionalVariableInstantiationRef::from(initial_rewritten.copy()); + + // SAFETY: the name is a subterm of `initial_rewritten`, still in scope. + let initial_eq_idx = *name_to_eq + .get(&unsafe { name_key(initial_pvi.name()) }) + .ok_or_else(|| MercError::from(format!("Unknown initial equation: {}", initial_pvi.name())))?; + + let mut initial_state = Vec::with_capacity(1 + num_params); + initial_state.push(initial_eq_idx); + for arg in initial_pvi.arguments().iter() { + // SAFETY: the term is interned into `value_mapping`, a `Protected` + // container that keeps every interned term live through GC marking + // for as long as the mapping exists. + let (idx, _) = value_mapping.insert(unsafe { DataExpressionRef::from_address(arg.address()) }); + initial_state.push(idx); + } + drop(rewriter); + + let true_summand_idx = num_equations; + let false_summand_idx = num_equations + 1; + let subformula_summand_idx = num_equations + 2; + + let mut summands: Vec = Vec::with_capacity(num_equations + 3); + + for (eq_idx, eq) in equations.iter().enumerate() { + let formula = eq.formula(); + let (read_positions, effect) = formula_positions(&formula, &process_parameters); + summands.push(PbesSummand { + kind: PbesSummandKind::Equation { + formula, + priority: priorities[eq_idx], + }, + value_mapping: value_mapping.handle(), + subformula_mapping: subformula_mapping.handle(), + name_to_eq: name_to_eq.clone(), + num_params, + read_positions, + effect, + }); + } + + summands.push(PbesSummand { + kind: PbesSummandKind::TrueSink, + value_mapping: value_mapping.handle(), + subformula_mapping: subformula_mapping.handle(), + name_to_eq: name_to_eq.clone(), + num_params, + read_positions: vec![0], + // A sink's only transition is the self-loop, so nothing changes. + effect: OwnedStateEffect::Positions(vec![]), + }); + summands.push(PbesSummand { + kind: PbesSummandKind::FalseSink, + value_mapping: value_mapping.handle(), + subformula_mapping: subformula_mapping.handle(), + name_to_eq: name_to_eq.clone(), + num_params, + read_positions: vec![0], + // A sink's only transition is the self-loop, so nothing changes. + effect: OwnedStateEffect::Positions(vec![]), + }); + summands.push(PbesSummand { + kind: PbesSummandKind::Subformula, + value_mapping: value_mapping.handle(), + subformula_mapping: subformula_mapping.handle(), + name_to_eq: name_to_eq.clone(), + num_params, + read_positions: vec![0, 1], + // A subformula vertex expands into propositional variable + // instantiations, sinks or further subformula vertices, all of + // different lengths. + effect: OwnedStateEffect::Opaque, + }); + + let equation_summands: Vec> = (0..num_equations).map(|i| vec![i]).collect(); + + Ok(PbesLps { + pbes, + data_spec, + summands, + equation_summands, + true_sink_summands: vec![true_summand_idx], + false_sink_summands: vec![false_summand_idx], + subformula_summands: vec![subformula_summand_idx], + initial_state, + process_parameters, + num_params, + num_equations, + name_to_eq, + value_mapping, + subformula_mapping, + }) + } + + /// Only used to size the group in tests; the tool derives the degree from + /// [`crate::explore_common::symmetry_parameter_basis`] instead, which does + /// not need a constructed LPS. + #[cfg(test)] + pub fn num_params(&self) -> usize { + self.num_params + } + + /// The unified data parameters, in state-vector order: entry `i` occupies + /// state position `1 + i` of a propositional variable instantiation. + /// + /// This is the vector [`crate::explore_common::symmetry_parameter_basis`] + /// returns, since both unify with the same flags, but a caller that permutes + /// parameter positions should check rather than assume that. + pub fn parameters(&self) -> Vec { + self.pbes.equations()[0].variable().parameters().iter().collect() + } +} + +impl LPS for PbesLps { + type Value = usize; + type Label = (); + type StateInfo = PbesVertex; + type Summand = PbesSummand; + + fn initial_state(&self) -> Vec { + self.initial_state.clone() + } + + fn summands(&self) -> &[PbesSummand] { + &self.summands + } + + fn create_context(&self) -> PbesContext { + PbesContext { + // The data specification was already accepted (and normalised) when + // this explorer was constructed, so building another rewriter for it + // cannot fail here. + rewrite: PbesRewriteContext::from_data_spec(&self.data_spec) + .expect("the data specification was already accepted during construction"), + parameter_values: Vec::with_capacity(self.num_params), + next_state_buf: Vec::with_capacity(1 + self.num_params), + chain_stack: PbesFlattenStack::new(), + psi: None, + player_priority: None, + } + } + + fn prepare<'a>(&'a self, context: &mut PbesContext, state: &'a [usize]) -> impl Iterator + 'a { + let tag = state[0] & TAG_MASK; + if tag == TRUE_SINK { + self.true_sink_summands.iter().copied() + } else if tag == FALSE_SINK { + self.false_sink_summands.iter().copied() + } else if tag == AND_OP || tag == OR_OP { + self.subformula_summands.iter().copied() + } else { + debug_assert!(tag == 0, "unexpected state tag {tag:#x}"); + let eq_idx = state[0]; + + context.parameter_values.clear(); + for &vi in &state[1..=self.num_params] { + context.parameter_values.push( + self.value_mapping + .get_by_index(vi) + .expect("parameter value must be in mapping") + .address(), + ); + } + + // SAFETY: `process_parameters` and `parameter_values` are live term + // pointers from the global pool; the rewriter produces a protected + // result that is immediately stored in `context.psi`. + let psi = unsafe { + context + .rewrite + .set_assignments(&self.process_parameters, &context.parameter_values); + context + .rewrite + .rewrite_formula(self.summands[eq_idx].formula().unwrap()) + } + .expect("the rewriter cannot evaluate the right-hand side of this equation"); + + let priority = self.summands[eq_idx].priority().unwrap(); + let player = player_of(&psi); + context.psi = Some(psi); + context.player_priority = Some((player, Priority::new(priority))); + + self.equation_summands[eq_idx].iter().copied() + } + } + + fn state_info(&self, state: &[usize], context: &PbesContext) -> PbesVertex { + let tag = state[0] & TAG_MASK; + if tag == TRUE_SINK { + PbesVertex::new(Player::Even, Priority::new(0), PbesVertexKind::Sink) + } else if tag == FALSE_SINK { + PbesVertex::new(Player::Odd, Priority::new(1), PbesVertexKind::Sink) + } else if tag == AND_OP { + PbesVertex::new( + Player::Odd, + Priority::new(SUBFORMULA_PRIORITY), + PbesVertexKind::Subformula, + ) + } else if tag == OR_OP { + PbesVertex::new( + Player::Even, + Priority::new(SUBFORMULA_PRIORITY), + PbesVertexKind::Subformula, + ) + } else { + let (player, priority) = context + .player_priority + .expect("prepare must be called before state_info"); + PbesVertex::instantiation(player, priority) + } + } +} + +impl ParameterLayoutLPS for PbesLps { + fn parameter_range(&self, state: &[usize]) -> Option> { + // Every tagged state (sink or subformula vertex) stores something other + // than parameters; an untagged state[0] is an equation index. + if state[0] & TAG_MASK == 0 { + debug_assert_eq!(state.len(), 1 + self.num_params); + Some(1..1 + self.num_params) + } else { + None + } + } +} + +impl PbesSummand { + fn formula(&self) -> Option<&mcrl2::PbesExpression> { + match &self.kind { + PbesSummandKind::Equation { formula, .. } => Some(formula), + _ => None, + } + } + + fn priority(&self) -> Option { + match &self.kind { + PbesSummandKind::Equation { priority, .. } => Some(*priority), + _ => None, + } + } + + fn tables(&self) -> TargetTables<'_> { + TargetTables { + name_to_eq: &self.name_to_eq, + value_mapping: &self.value_mapping, + subformula_mapping: &self.subformula_mapping, + } + } +} + +impl Summand for PbesSummand { + type Value = usize; + type Label = (); + type Context = PbesContext; + + fn read_positions(&self) -> &[usize] { + &self.read_positions + } + + fn effect(&self) -> StateEffect<'_> { + self.effect.borrow() + } + + fn enumerate(&self, context: &mut PbesContext, state: &[usize], mut report: F) -> Result<(), MercError> + where + F: FnMut(&(), &[usize]) -> Result<(), MercError>, + { + match &self.kind { + PbesSummandKind::TrueSink => { + context.next_state_buf.clear(); + context.next_state_buf.push(TRUE_SINK); + report(&(), &context.next_state_buf) + } + PbesSummandKind::FalseSink => { + context.next_state_buf.clear(); + context.next_state_buf.push(FALSE_SINK); + report(&(), &context.next_state_buf) + } + PbesSummandKind::Equation { .. } => { + let psi = context.psi.as_ref().expect("prepare must precede enumerate"); + let formula = psi.copy(); + enumerate_formula_children( + formula, + self.tables(), + &mut context.chain_stack, + &mut context.next_state_buf, + &mut report, + ) + } + PbesSummandKind::Subformula => { + let subformula_idx = state[1]; + let pbes_ref = self + .subformula_mapping + .get_by_index(subformula_idx) + .expect("subformula index must be valid"); + let formula = pbes_ref.copy(); + enumerate_formula_children( + formula, + self.tables(), + &mut context.chain_stack, + &mut context.next_state_buf, + &mut report, + ) + } + } + } +} + +/// Returns the player for the given top-level PBES formula. +fn player_of(psi: &mcrl2::PbesExpression) -> Player { + let r = psi.copy(); + if is_pbes_and(&r) || is_pbes_false(&r) { + Player::Odd + } else { + // OR, PVI, true — player Even (existential / single outgoing edge) + Player::Even + } +} + +/// Emits successor states for the given PBES formula. +/// +/// AND/OR chains are flattened (like mCRL2's `split_and`/`split_or`) so that +/// `(A && B) && (C && D)` emits 4 direct edges instead of 2 subformula vertices. +/// Cross-operator nesting still produces subformula vertices via [`emit_as_target`]. +fn enumerate_formula_children( + formula: PbesExpressionRef<'_>, + tables: TargetTables<'_>, + stack: &mut PbesFlattenStack, + buf: &mut Vec, + report: &mut F, +) -> Result<(), MercError> +where + F: FnMut(&(), &[usize]) -> Result<(), MercError>, +{ + // Only the chain's own operator is flattened: a nested operand of the other + // one becomes a subformula vertex in `emit_as_target`, and a leaf (a PVI, + // `true` or `false`) is a chain of one that comes back unchanged. + let connective = if is_pbes_or(&formula.copy()) { + PbesConnective::Or + } else { + PbesConnective::And + }; + + for leaf in PbesFlattenIter::new(formula, connective, stack) { + emit_as_target(leaf, tables, buf, report)?; + } + + Ok(()) +} + +/// Emits a transition to the parity-game state corresponding to `expr`. +/// +/// PVI → concrete PVI state `[eq_idx, arg0, …]`. +/// AND/OR sub-formula → subformula vertex `[AND_OP/OR_OP, subformula_idx]`. +/// `true` / `false` → respective sink state. +/// +/// A sub-formula target is always a strict subterm of `expr`'s parent formula, +/// which is what makes the subformula subgraph acyclic and lets those vertices +/// share [`SUBFORMULA_PRIORITY`] regardless of which equation reached them. +fn emit_as_target( + expr: PbesExpressionRef<'_>, + tables: TargetTables<'_>, + buf: &mut Vec, + report: &mut F, +) -> Result<(), MercError> +where + F: FnMut(&(), &[usize]) -> Result<(), MercError>, +{ + if is_pbes_propositional_variable_instantiation(&expr.copy()) { + let pvi = PbesPropositionalVariableInstantiationRef::from(expr); + let target_eq = *tables + .name_to_eq + // SAFETY: the name is a subterm of `expr`, which the caller holds live. + .get(&unsafe { name_key(pvi.name()) }) + .ok_or_else(|| MercError::from(format!("Unknown equation name in PVI: {}", pvi.name())))?; + buf.clear(); + buf.push(target_eq); + for arg in pvi.arguments().iter() { + // SAFETY: term interned into the Protected value_mapping. + let (idx, _) = tables + .value_mapping + .insert(unsafe { DataExpressionRef::from_address(arg.address()) }); + buf.push(idx); + } + report(&(), buf) + } else if is_pbes_and(&expr.copy()) { + // SAFETY: term is a sub-expression of the rewritten psi still in context; + // the subformula_mapping (Protected) keeps it alive via GC marking. + let (subformula_idx, _) = tables + .subformula_mapping + .insert(unsafe { PbesExpressionRef::from_address(expr.address()) }); + buf.clear(); + buf.extend([AND_OP, subformula_idx]); + report(&(), buf) + } else if is_pbes_or(&expr.copy()) { + // SAFETY: term is a sub-expression of the rewritten psi still in context; + // the subformula_mapping (Protected) keeps it alive via GC marking. + let (subformula_idx, _) = tables + .subformula_mapping + .insert(unsafe { PbesExpressionRef::from_address(expr.address()) }); + buf.clear(); + buf.extend([OR_OP, subformula_idx]); + report(&(), buf) + } else if is_pbes_true(&expr.copy()) { + buf.clear(); + buf.push(TRUE_SINK); + report(&(), buf) + } else if is_pbes_false(&expr.copy()) { + buf.clear(); + buf.push(FALSE_SINK); + report(&(), buf) + } else { + Err(MercError::from(format!( + "Unexpected PBES formula shape after rewriting: {}", + expr.copy() + ))) + } +} + +/// Builds a [`ParityGame`] by exploring the given PBES directly (no SRF conversion). +pub fn explore_pbes( + pbes: Pbes, + strategy: ExplorationStrategy, + caching: CachingStrategy, + timing: &Timing, +) -> Result { + let lps = PbesLps::new(pbes)?; + match caching { + CachingStrategy::None => explore_pbes_impl(&lps, strategy, timing), + _ => { + let cached = CacheLPS::new(&lps, caching); + let game = explore_pbes_impl(&cached, strategy, timing)?; + debug!("{}", cached.metrics()); + Ok(game) + } + } +} + +/// Builds a [`ParityGame`] by exploring the given PBES directly in parallel. +pub fn explore_pbes_parallel( + pbes: Pbes, + threads: usize, + caching: CachingStrategy, + pinned: bool, + timing: &Timing, +) -> Result { + let lps = PbesLps::new(pbes)?; + match caching { + CachingStrategy::None => explore_pbes_parallel_impl(&lps, threads, pinned, timing), + _ => { + let cached = CacheLPS::new(&lps, caching); + let game = explore_pbes_parallel_impl(&cached, threads, pinned, timing)?; + debug!("{}", cached.metrics()); + Ok(game) + } + } +} + +/// Computes the read positions and the state effect of an equation summand. +/// +/// The effect is [`StateEffect::Positions`] only when the right-hand side is a +/// bare propositional variable instantiation. Such an equation always produces +/// exactly one next state, of the same length as the source, so the written +/// positions describe it exactly. +/// +/// Every other shape is [`StateEffect::Opaque`]. It is not enough to look for a +/// syntactic `&&`/`||`: the right-hand side is rewritten with quantifier +/// enumeration before it is explored, and that turns a `forall`/`exists` into an +/// and/or chain (a subformula vertex, length 3) and can collapse a `val(...)` to +/// `true`/`false` (a sink, length 1). Neither has the length of the source state, +/// so no set of write positions can describe them. +/// +/// `read_positions` is `{0}` ∪ `{k+1 | param[k]` occurs in `formula}`. Identity +/// arguments (`X(..., d_k, ...)` passing `d_k` straight through) only count under +/// an opaque effect: there the whole next state is captured, so a passed-through +/// value has to be part of the cache key, whereas a positional effect replays it +/// from the live source state. +fn formula_positions(formula: &mcrl2::PbesExpression, params: &[*const _aterm]) -> (Vec, OwnedStateEffect) { + // Single-pass visitor: collect read-variable addresses and the write-position + // mask simultaneously, visiting each PVI argument exactly once. + struct FormulaPositions<'p> { + /// Variables occurring anywhere other than as an identity PVI argument. + var_addrs: HashSet<*const _aterm>, + /// Parameters passed straight through by some PVI argument. + identity_var_addrs: HashSet<*const _aterm>, + /// Parameter positions some PVI argument writes a non-identity value to. + write_mask: Vec, + params: &'p [*const _aterm], + } + + impl PbesExpressionVisitor for FormulaPositions<'_> { + fn visit_propositional_variable_instantiation( + &mut self, + inst: &PbesPropositionalVariableInstantiationRef<'_>, + ) -> Option { + for (k, (arg, ¶m_addr)) in inst.arguments().iter().zip(self.params.iter()).enumerate() { + if is_variable(&arg.copy()) && arg.address() == param_addr { + self.identity_var_addrs.insert(param_addr); + continue; + } + for v in variable_occurrences_data_expression(&arg.copy()) { + self.var_addrs.insert(v.address()); + } + self.write_mask[k] = true; + } + None + } + + fn visit_data_expression(&mut self, expr: &DataExpressionRef<'_>) -> Option { + for v in variable_occurrences_data_expression(expr) { + self.var_addrs.insert(v.address()); + } + None + } + } + + let mut collector = FormulaPositions { + var_addrs: HashSet::new(), + identity_var_addrs: HashSet::new(), + write_mask: vec![false; params.len()], + params, + }; + collector.visit(&formula.copy()); + + // Rewriting a propositional variable instantiation rewrites its arguments but + // cannot change its shape, so this is the one case with a positional effect. + let is_bare_instantiation = is_pbes_propositional_variable_instantiation(&formula.copy()); + + // Position 0 (the equation index) is always read and always written. + let mut read_positions = vec![0usize]; + for (k, ¶m_addr) in params.iter().enumerate() { + let read = collector.var_addrs.contains(¶m_addr) + || (!is_bare_instantiation && collector.identity_var_addrs.contains(¶m_addr)); + if read { + read_positions.push(k + 1); + } + } + + let effect = if is_bare_instantiation { + let mut write_positions = vec![0usize]; + for (k, &written) in collector.write_mask.iter().enumerate() { + if written { + write_positions.push(k + 1); + } + } + OwnedStateEffect::Positions(write_positions) + } else { + OwnedStateEffect::Opaque + }; + + (read_positions, effect) +} diff --git a/tools/mcrl2/pbes/src/explore_srf.rs b/tools/mcrl2/crates/merc_pbes/src/explore_srf.rs similarity index 66% rename from tools/mcrl2/pbes/src/explore_srf.rs rename to tools/mcrl2/crates/merc_pbes/src/explore_srf.rs index f7f72c7c7..9b70d1043 100644 --- a/tools/mcrl2/pbes/src/explore_srf.rs +++ b/tools/mcrl2/crates/merc_pbes/src/explore_srf.rs @@ -1,9 +1,8 @@ -use std::cell::Cell; use std::collections::HashMap; +use std::ops::Range; use std::sync::Arc; use log::debug; -use log::info; use mcrl2::_aterm; use mcrl2::ATerm; @@ -24,188 +23,70 @@ use merc_explore::CacheLPS; use merc_explore::CachingStrategy; use merc_explore::ExplorationStrategy; use merc_explore::LPS; +use merc_explore::StateEffect; use merc_explore::Summand; -use merc_explore::configure_rayon_thread_pool; -use merc_explore::explore; -use merc_explore::explore_parallel; -use merc_io::TimeProgress; -use merc_lts::StateIndex; use merc_unsafety::ConcurrentIndexedSet; use merc_utilities::MercError; use merc_utilities::Timing; use merc_vpg::ParityGame; -use merc_vpg::ParityGameBuilder; use merc_vpg::Player; use merc_vpg::Priority; -use merc_vpg::VertexIndex; - -/// Periodic progress reporter for PBES exploration. A PBES state is a BES -/// equation (parity-game vertex), so the count is reported as BES equations. -fn bes_progress() -> TimeProgress<(usize, usize)> { - TimeProgress::new( - |(equations, edges): (usize, usize)| { - info!("Explored {equations} BES equations, {edges} edges..."); - }, - 1, - ) -} + +use crate::explore_common::ParameterLayoutLPS; +use crate::explore_common::PbesVertex; +use crate::explore_common::UNIFY_IGNORE_CE_EQUATIONS; +use crate::explore_common::UNIFY_RESET_PARAMETERS; +use crate::explore_common::compute_priorities; +use crate::explore_common::explore_pbes_impl; +use crate::explore_common::explore_pbes_parallel_impl; /// Builds a [`ParityGame`] by exploring the given PBES in SRF format. -pub(crate) fn parity_game_from_pbes( +pub fn explore_srf_pbes( pbes: &Pbes, strategy: ExplorationStrategy, caching: CachingStrategy, + timing: &Timing, ) -> Result { let lps = PbesSrfLps::new(pbes)?; - let timing = Timing::new(); - // Only layer the enumeration cache on top of the LPS when a caching strategy - // is actually requested; otherwise explore the bare LPS directly. match caching { - CachingStrategy::None => run_explore_srf(&lps, strategy, &timing), + CachingStrategy::None => explore_pbes_impl(&lps, strategy, timing), _ => { let cached = CacheLPS::new(&lps, caching); - let game = run_explore_srf(&cached, strategy, &timing)?; + let game = explore_pbes_impl(&cached, strategy, timing)?; debug!("{}", cached.metrics()); Ok(game) } } } -/// Runs the sequential SRF PBES exploration loop over any [`LPS`] view producing -/// unit labels and `(Player, Priority)` state info, building the parity game. The -/// view is either the bare LPS or one wrapped in [`CacheLPS`]. -fn run_explore_srf(lps: &M, strategy: ExplorationStrategy, timing: &Timing) -> Result -where - M: LPS, -{ - let mut builder = ParityGameBuilder::new(VertexIndex::new(0)); - - // Count BES equations (vertices) and edges in the exploration closures, - // driving the periodic progress reporter from `on_transition`. - let progress = bes_progress(); - let equations = Cell::new(0usize); - let edges = Cell::new(0usize); - - let _initial = explore( - lps, - strategy, - timing, - &mut builder, - |b: &mut ParityGameBuilder, state: StateIndex, info: &(Player, Priority)| { - equations.set(equations.get() + 1); - b.add_vertex(VertexIndex::new(state.value()), info.0, info.1); - Ok(()) - }, - |b: &mut ParityGameBuilder, from: StateIndex, _label: &(), to: StateIndex| { - edges.set(edges.get() + 1); - progress.print((equations.get(), edges.get())); - b.add_edge(VertexIndex::new(from.value()), VertexIndex::new(to.value())); - Ok(()) - }, - )?; - info!( - "Exploration complete: {} BES equations, {} edges", - equations.get(), - edges.get(), - ); - - Ok(builder.finish(true, true)) -} - -/// Per-worker output partition for [`parity_game_from_pbes_parallel`]. -/// -/// Holds the vertices and edges discovered by one worker. All values are dense -/// `usize`-backed indices (and plain `Player`/`Priority`), so the partition is -/// `Send` and the partitions merge by concatenation without any remapping. -#[derive(Default)] -struct PbesPartition { - vertices: Vec<(VertexIndex, Player, Priority)>, - edges: Vec<(VertexIndex, VertexIndex)>, -} - -/// Builds a [`ParityGame`] by exploring the given PBES in SRF format in parallel -/// across `threads` worker threads. -pub(crate) fn parity_game_from_pbes_parallel( +/// Builds a [`ParityGame`] by exploring the given PBES in SRF format in parallel. +pub fn explore_srf_pbes_parallel( pbes: &Pbes, threads: usize, caching: CachingStrategy, pinned: bool, + timing: &Timing, ) -> Result { let lps = PbesSrfLps::new(pbes)?; - let pool = configure_rayon_thread_pool(threads, pinned)?; - - // Only layer the enumeration cache on top of the LPS when a caching strategy - // is actually requested; otherwise explore the bare LPS directly. match caching { - CachingStrategy::None => run_explore_srf_parallel(&lps, &pool), + CachingStrategy::None => explore_pbes_parallel_impl(&lps, threads, pinned, timing), _ => { let cached = CacheLPS::new(&lps, caching); - let game = run_explore_srf_parallel(&cached, &pool)?; + let game = explore_pbes_parallel_impl(&cached, threads, pinned, timing)?; debug!("{}", cached.metrics()); Ok(game) } } } -/// Runs the parallel SRF PBES exploration over any [`LPS`] view, merging the -/// per-worker partitions into a parity game. The view is either the bare LPS or -/// one wrapped in [`CacheLPS`]. -fn run_explore_srf_parallel(lps: &M, pool: &rayon::ThreadPool) -> Result -where - M: LPS + Sync, - ::Context: Send, -{ - let timing = Timing::new(); - let (_initial, partitions) = timing.measure("explore", || { - pool.install(|| { - explore_parallel( - lps, - PbesPartition::default, - |partition: &mut PbesPartition, state: StateIndex, info: &(Player, Priority)| { - partition - .vertices - .push((VertexIndex::new(state.value()), info.0, info.1)); - Ok(()) - }, - |partition: &mut PbesPartition, from: StateIndex, _label: &(), to: StateIndex| { - partition - .edges - .push((VertexIndex::new(from.value()), VertexIndex::new(to.value()))); - Ok(()) - }, - ) - }) - })?; - - let total_equations: usize = partitions.iter().map(|p| p.vertices.len()).sum(); - let total_edges: usize = partitions.iter().map(|p| p.edges.len()).sum(); - info!("Exploration complete: {total_equations} BES equations, {total_edges} edges"); - - // Merge the per-worker partitions: every state is reported to `on_state` - // exactly once, so each vertex is added once. Add all vertices before edges - // since an edge may target a vertex discovered by another worker. - let mut builder = ParityGameBuilder::new(VertexIndex::new(0)); - for partition in &partitions { - for &(vertex, player, priority) in &partition.vertices { - builder.add_vertex(vertex, player, priority); - } - } - for partition in &partitions { - for &(from, to) in &partition.edges { - builder.add_edge(from, to); - } - } - Ok(builder.finish(true, true)) -} - /// Per-thread enumeration context for a [`PbesSrfLps`]. /// /// Owns the mCRL2 enumeration backend and the reusable scratch buffers, so the /// LPS and its summands stay immutable and shareable by `&self` while each /// worker thread drives its own context. -pub(crate) struct PbesSrfContext { +pub struct PbesSrfContext { /// Backend used to evaluate summand conditions and enumerate solutions, /// staged per source state by [`LPS::prepare`]. context: LearnSuccessorsContext, @@ -233,9 +114,10 @@ unsafe impl Send for PbesSrfContext {} /// State vectors have layout `[equation_index, param_0, …, param_{n-1}]` where /// `equation_index` is a flat index into [`SrfPbes::equations`] and each /// `param_i` is an index into the shared [`ValueMapping`]. -pub(crate) struct PbesSrfLps { - /// The unified SRF PBES; retained so summand pointers stay alive. - _srf: SrfPbes, +pub struct PbesSrfLps { + /// The unified SRF PBES; retained so summand pointers stay alive, and read + /// back by [`PbesSrfLps::parameters`]. + srf: SrfPbes, /// Data specification used to build each per-thread [`PbesSrfContext`]. data_spec: DataSpecification, @@ -251,8 +133,9 @@ pub(crate) struct PbesSrfLps { /// The initial state vector. initial_state: Vec, - /// Per-equation (Player, Priority) used by [`LPS::state_info`]. - state_info: Vec<(Player, Priority)>, + /// Per-equation vertex description used by [`LPS::state_info`]. Every SRF + /// state stands for a propositional variable instantiation. + state_info: Vec, /// Cached data-parameter variables (length `num_params`). All equations /// share the same parameter list after [`SrfPbes::unify_parameters`]. @@ -278,7 +161,7 @@ unsafe impl Sync for PbesSrfLps {} /// A single SRF summand, pre-bound to the equation it belongs to and the /// target equation it transitions into. -pub(crate) struct PbesSrfSummand { +pub struct PbesSrfSummand { /// Source equation index; the summand fires only when `state[0]` equals it. equation_index: usize, @@ -318,16 +201,17 @@ pub(crate) struct PbesSrfSummand { impl PbesSrfLps { /// Constructs a new [`PbesSrfLps`] from a PBES by normalising it to SRF and /// unifying the parameter lists. - pub(crate) fn new(pbes: &Pbes) -> Result { + pub fn new(pbes: &Pbes) -> Result { let mut srf = SrfPbes::from(pbes)?; - srf.unify_parameters(false, true)?; + srf.unify_parameters(UNIFY_IGNORE_CE_EQUATIONS, UNIFY_RESET_PARAMETERS)?; if srf.equations().is_empty() { return Err("PBES has no equations".into()); } let num_params = srf.equations()[0].variable().parameters().len(); - let priorities = compute_priorities(&srf); + let is_mu: Vec = srf.equations().iter().map(|e| e.is_mu()).collect(); + let priorities = compute_priorities(&is_mu); // Equation name -> equation index, used when resolving target PVIs. let name_to_eq: HashMap = srf @@ -339,13 +223,13 @@ impl PbesSrfLps { // (Player, Priority) per equation. PBES convention: conjunctive (∧) // is owned by ∀ (Odd), disjunctive (∨) is owned by ∃ (Even). - let state_info: Vec<(Player, Priority)> = srf + let state_info: Vec = srf .equations() .iter() .enumerate() .map(|(i, eq)| { let player = if eq.is_conjunctive() { Player::Odd } else { Player::Even }; - (player, Priority::new(priorities[i])) + PbesVertex::instantiation(player, Priority::new(priorities[i])) }) .collect(); @@ -443,7 +327,7 @@ impl PbesSrfLps { } Ok(Self { - _srf: srf, + srf, data_spec, summands, equation_summands, @@ -454,12 +338,22 @@ impl PbesSrfLps { value_mapping, }) } + + pub fn num_params(&self) -> usize { + self.num_params + } + + /// The unified data parameters, in state-vector order: entry `i` occupies + /// state position `1 + i`. + pub fn parameters(&self) -> Vec { + self.srf.equations()[0].variable().parameters().iter().collect() + } } impl LPS for PbesSrfLps { type Value = usize; type Label = (); - type StateInfo = (Player, Priority); + type StateInfo = PbesVertex; type Summand = PbesSrfSummand; fn initial_state(&self) -> Vec { @@ -508,11 +402,19 @@ impl LPS for PbesSrfLps { self.equation_summands[state[0]].iter().copied() } - fn state_info(&self, state: &[Self::Value]) -> Self::StateInfo { + fn state_info(&self, state: &[Self::Value], _context: &PbesSrfContext) -> Self::StateInfo { self.state_info[state[0]] } } +impl ParameterLayoutLPS for PbesSrfLps { + fn parameter_range(&self, state: &[usize]) -> Option> { + // Every SRF state is `[equation_index, params...]`. + debug_assert_eq!(state.len(), 1 + self.num_params()); + Some(1..1 + self.num_params()) + } +} + impl Summand for PbesSrfSummand { type Value = usize; type Label = (); @@ -522,8 +424,10 @@ impl Summand for PbesSrfSummand { &self.read_positions } - fn write_positions(&self) -> &[usize] { - &self.write_positions + fn effect(&self) -> StateEffect<'_> { + // An SRF summand always emits `[target_equation, params...]`, which has + // the same length as every source state. + StateEffect::Positions(&self.write_positions) } fn enumerate(&self, context: &mut Self::Context, state: &[usize], mut report: F) -> Result<(), MercError> @@ -587,57 +491,3 @@ impl Summand for PbesSrfSummand { report_result } } - -/// Computes a priority for each equation for a **max** parity game. -/// -/// Algorithm: -/// 1. Assign each equation an *alternation depth* (incremented on every -/// μ ↔ ν switch), so the outermost block has depth 0 and the innermost -/// has depth `max_depth`. -/// 2. Reverse: `priority = max_depth − depth`, making the outermost block -/// the highest-priority block. -/// 3. Shift all priorities by 1 when the outermost equation's current parity -/// does not match its fixpoint type (ν → even, μ → odd). -fn compute_priorities(srf: &SrfPbes) -> Vec { - let equations = srf.equations(); - if equations.is_empty() { - return Vec::new(); - } - - // Step 1: compute alternation depth per equation. - let mut depths = vec![0usize; equations.len()]; - let mut current_depth = 0usize; - let mut prev_is_mu = equations[0].is_mu(); - - for (i, eq) in equations.iter().enumerate() { - let is_mu = eq.is_mu(); - if i > 0 && is_mu != prev_is_mu { - current_depth += 1; - } - depths[i] = current_depth; - prev_is_mu = is_mu; - } - - // Step 2: reverse so outermost (depth 0) → highest priority (max_depth). - let max_depth = *depths.last().unwrap(); - let mut priorities: Vec = depths.iter().map(|&d| max_depth - d).collect(); - - // Step 3: shift all priorities by 1 iff the outermost equation's priority - // parity does not match its fixpoint type (ν needs even, μ needs odd). - let first_is_mu = equations[0].is_mu(); - if first_is_mu == priorities[0].is_multiple_of(2) { - for p in &mut priorities { - *p += 1; - } - } - - debug_assert!( - priorities - .iter() - .zip(equations.iter()) - .all(|(p, eq)| p.is_multiple_of(2) != eq.is_mu()), - "Max parity game invariant violated: ν must have even priority and μ must have odd priority" - ); - - priorities -} diff --git a/tools/mcrl2/pbes/src/explore_symbolic_srf.rs b/tools/mcrl2/crates/merc_pbes/src/explore_symbolic_srf.rs similarity index 86% rename from tools/mcrl2/pbes/src/explore_symbolic_srf.rs rename to tools/mcrl2/crates/merc_pbes/src/explore_symbolic_srf.rs index e5d92e3a7..4992471a4 100644 --- a/tools/mcrl2/pbes/src/explore_symbolic_srf.rs +++ b/tools/mcrl2/crates/merc_pbes/src/explore_symbolic_srf.rs @@ -17,11 +17,7 @@ use crate::explore_srf::PbesSrfLps; /// machinery (equation-index gating via `prepare`, condition enumeration, /// read/write positions) is reused from the explicit [`PbesSrfLps`] through the /// generic [`SymbolicLps`] adapter, shared with LPS symbolic exploration. -pub(crate) fn explore_pbes_symbolic( - storage: &LDDManagerRef, - pbes: &Pbes, - timing: &Timing, -) -> Result { +pub fn explore_pbes_symbolic(storage: &LDDManagerRef, pbes: &Pbes, timing: &Timing) -> Result { let lps = PbesSrfLps::new(pbes)?; let mut symbolic = SymbolicLps::new(storage, lps)?; diff --git a/tools/mcrl2/crates/merc_pbes/src/graph_symmetry.rs b/tools/mcrl2/crates/merc_pbes/src/graph_symmetry.rs new file mode 100644 index 000000000..5b2034d80 --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/src/graph_symmetry.rs @@ -0,0 +1,1641 @@ +// Authors: Menno Bartels and Maurice Laveaux + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::collections::HashSet; +use std::fmt; +use std::fs; +use std::io::ErrorKind; +use std::io::Write; +use std::path::PathBuf; + +use itertools::Itertools; +use log::debug; +use log::info; +use log::trace; +use petgraph::graph::NodeIndex; +use petgraph::graph::UnGraph; +use petgraph::graph6::ToGraph6; + +use mcrl2::ATermRef; +use mcrl2::DataAbstractionRef; +use mcrl2::DataApplicationRef; +use mcrl2::DataExpressionRef; +use mcrl2::DataFunctionSymbolRef; +use mcrl2::DataMachineNumberRef; +use mcrl2::DataVariable; +use mcrl2::DataVariableRef; +use mcrl2::Pbes; +use mcrl2::PbesExistsRef; +use mcrl2::PbesExpression; +use mcrl2::PbesExpressionRef; +use mcrl2::PbesForallRef; +use mcrl2::PbesImpRef; +use mcrl2::PbesNotRef; +use mcrl2::PbesPropositionalVariableInstantiation; +use mcrl2::PbesPropositionalVariableInstantiationRef; +use mcrl2::SortExpression; +use mcrl2::flatten_associative; +use mcrl2::is_abstraction; +use mcrl2::is_application; +use mcrl2::is_function_symbol; +use mcrl2::is_machine_number; +use mcrl2::is_pbes_and; +use mcrl2::is_pbes_exists; +use mcrl2::is_pbes_forall; +use mcrl2::is_pbes_imp; +use mcrl2::is_pbes_not; +use mcrl2::is_pbes_or; +use mcrl2::is_pbes_propositional_variable_instantiation; +use mcrl2::is_untyped_identifier; +use mcrl2::is_variable; +use mcrl2::is_where_clause; +use mcrl2::pbes_expression_pvi; +use merc_utilities::MercError; + +use crate::explore_common::UNIFY_IGNORE_CE_EQUATIONS; +use crate::explore_common::UNIFY_RESET_PARAMETERS; +use crate::permutation::Permutation; + +/// Binary function symbols treated as commutative; listing a non-commutative one unsoundly widens the symmetry group. +const COMMUTATIVE_FUNCTION_SYMBOLS: &[&str] = &["&&", "||", "==", "!=", "<=>", "+", "*", "max", "min"]; + +/// Subset of [`COMMUTATIVE_FUNCTION_SYMBOLS`] that are also associative, and as +/// such can be flattened. +const ASSOCIATIVE_FUNCTION_SYMBOLS: &[&str] = &["&&", "||", "+", "*", "max", "min"]; + +/// True iff `name` is a known commutative binary symbol at the given arity. +fn is_commutative(name: &str, arity: usize) -> bool { + arity == 2 && COMMUTATIVE_FUNCTION_SYMBOLS.contains(&name) +} + +/// True iff `name` is associative-commutative and its chains should be flattened into one n-ary SDG vertex. +fn is_flat_operator(name: &str, arity: usize) -> bool { + arity == 2 && ASSOCIATIVE_FUNCTION_SYMBOLS.contains(&name) +} + +/// Collects leaves of a nested binary PBES connective chain; analogous to [`flatten_associative`] for non-`is_application` connectives. +fn collect_pbes_flat(term: PbesExpressionRef<'_>, is_op: F, out: &mut Vec) +where + F: Fn(&ATermRef<'_>) -> bool + Copy, +{ + if is_op(&term) { + collect_pbes_flat(term.arg(0).into(), is_op, out); + collect_pbes_flat(term.arg(1).into(), is_op, out); + } else { + out.push(term.protect()); + } +} + +/// A vertex of the symmetry detection graph. +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +enum SdgVertex { + /// The k'th (0-based) data parameter `d_k`. Allocated first, so + /// `NodeIndex(k) == Parameter(k)`. + Parameter(usize), + + /// A subformula or subterm, identified by its (maximally shared) term. + /// Since mCRL2 terms are hash-consed, using the term itself as the + /// deduplication key is exactly Definition 2's `sub(E)` being a *set*: + /// two occurrences of the syntactically identical subterm (even across + /// different equations) collapse to a single vertex, with [`Sdg`]'s + /// per-vertex `C_eq` accumulating every equation that reaches it. + /// + /// Typed as a [`PbesExpression`] since that is exactly what is walked: the + /// PBES connectives, and the data expressions below them (in mCRL2 every + /// data expression is a PBES expression). + Term(PbesExpression), + + /// The synthetic update position `X_{i,k}`: not a PBES term, and never + /// deduplicated (one fresh vertex per `(equation, pvi-index, + /// parameter-index)` triple, by construction). + Update { + equation: usize, + pvi: usize, + parameter: usize, + }, +} + +/// `C(v)`, the "structural" colour of a vertex, excluding the orthogonal +/// `C_eq` component (see [`Sdg::equations`]). +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +enum VertexColour { + /// `C(x) = par` for a PBES parameter vertex, refined by the parameter's sort. + /// + /// The sort is part of the colour because function symbols are coloured by + /// name alone (`==` and `<` exist at every sort), so without it two parameters + /// of different sorts with isomorphic neighbourhoods would be interchangeable + /// and GAP would report cross-sort permutations. Those are not symmetries: the + /// quotient would feed a value of the wrong sort to `set_assignments`. + Parameter(SortExpression), + + /// A quantifier-bound variable, coloured by its sort but, like + /// [`VertexColour::Quantifier`], deliberately not by name (matching the + /// paper's note that the bound variable's name is not part of a + /// quantifier's colour) -- otherwise an automorphism could map a bound + /// variable onto an unrelated PBES parameter of the same sort. + BoundVariable(SortExpression), + + /// `C(f(t1,...,tk)) = f`, identified by name. Also used for nullary + /// function symbols/constants (`sub#(f()) = {}`, so these are leaves). + Function(String), + + /// A machine number constant, coloured by its value so that an + /// automorphism cannot equate two different constants. + MachineNumber(u64), + + /// `C(phi1 (+) phi2) = (+)` for `(+) in {and, or, not, imp}` (the paper's + /// grammar only has `{and, or}`; `not`/`imp` are additional mCRL2 + /// connectives given their own distinct colours the same way). + Connective(Connective), + + /// `C(Qe:D.phi) = (Q,D)`. Generalized from a single sort to a vector of + /// sorts, since mCRL2 quantifiers may bind more than one variable at + /// once (`forall e1:D1, e2:D2 . phi`); this specializes to the paper's + /// exact `(Q,D)` when exactly one variable is bound. + Quantifier(Quantifier, Vec), + + /// `C(X(t1,...,tn)) = pvi`. + Pvi, + + /// `C(X_{i,k}) = update`. + Update, +} + +impl fmt::Display for VertexColour { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + VertexColour::Parameter(s) => write!(f, "par:{s}"), + VertexColour::BoundVariable(s) => write!(f, "bvar:{s}"), + VertexColour::Function(name) => f.write_str(name), + VertexColour::MachineNumber(n) => write!(f, "{n}"), + VertexColour::Connective(c) => write!(f, "{c}"), + VertexColour::Quantifier(q, ss) => write!(f, "{q}:{}", ss.iter().format(",")), + VertexColour::Pvi => f.write_str("pvi"), + VertexColour::Update => f.write_str("update"), + } + } +} + +impl VertexColour { + fn dot_fill_colour(&self) -> &'static str { + match self { + VertexColour::Parameter(_) => "#aec6cf", + VertexColour::BoundVariable(_) => "#d5e8d4", + VertexColour::Function(_) => "#fff2cc", + VertexColour::MachineNumber(_) => "#ffe6cc", + VertexColour::Connective(_) => "#f8cecc", + VertexColour::Quantifier(..) => "#e1d5e7", + VertexColour::Pvi => "#dae8fc", + VertexColour::Update => "#f5f5f5", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum Connective { + And, + Or, + Not, + Imp, +} + +impl fmt::Display for Connective { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Connective::And => "&&", + Connective::Or => "||", + Connective::Not => "!", + Connective::Imp => "=>", + }) + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +enum Quantifier { + Forall, + Exists, + /// Data-level lambda abstraction (`lambda x:D. body`). + Lambda, + /// Data-level set/bag comprehension or untyped set/bag comprehension binder. + Comprehension, +} + +impl fmt::Display for Quantifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Quantifier::Forall => "forall", + Quantifier::Exists => "exists", + Quantifier::Lambda => "lambda", + Quantifier::Comprehension => "comp", + }) + } +} + +/// `C(e)`, the colour of an edge, passed to GAP as a native edge colour (no +/// port-vertex subdivision gadget is needed). +/// +/// Both set-valued variants implement the same idea: when an edge's +/// "natural" single label would coincide with another edge already present +/// between the same pair of vertices, the labels are combined into one set +/// instead of creating a duplicate `(source, target, colour)` triple (GAP's +/// `AutomorphismGroup` with edge colours disallows two edges sharing source, +/// range *and* colour). In the common, non-colliding case this is just a +/// singleton set, so it costs nothing and changes nothing versus the paper's +/// literal per-position/per-role reading. See [`SdgBuilder::add_or_merge_edge`]. +impl fmt::Display for EdgeColour { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EdgeColour::Uncoloured => Ok(()), + EdgeColour::Argument(positions) => write!(f, "{}", positions.iter().format(",")), + EdgeColour::Update(roles) => write!(f, "{}", roles.iter().format(",")), + } + } +} + +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +enum EdgeColour { + /// The set of positions `{i | t_i = psi}` of a non-commutative + /// function's argument `psi` -- a singleton `{i}` unless `psi` repeats + /// across positions (e.g. `f(x,y,x)` gets one edge to `x` coloured + /// `{1,3}`, not two parallel edges). + Argument(BTreeSet), + + /// Any argument of a commutative function; any other `C = 0` edge (`(+)` + /// operands, quantifier body, abstraction body, ...). + Uncoloured, + + /// The set of [`UpdateRole`]s that coincide on the same target vertex of + /// an update vertex `X_{i,k}` -- a singleton unless the PVI's k'th + /// argument is literally the current value of parameter `d_k` (a + /// "copy", which is extremely common in practice), in which case the + /// `Data` and `Par` edges land on the same vertex and are combined. + Update(BTreeSet), +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)] +enum UpdateRole { + Pvi, + Data, + Par, +} + +impl fmt::Display for UpdateRole { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + UpdateRole::Pvi => "pvi", + UpdateRole::Data => "data", + UpdateRole::Par => "par", + }) + } +} + +/// The symmetry detection graph (SDG) of a PBES, as constructed by +/// [`build_sdg`]. +pub struct Sdg { + /// Undirected, matching the paper's "nondirected colored graph". Exactly + /// one edge per `(phi, psi)` vertex pair: [`EdgeColour`]'s combined + /// labels resolve the only two situations that could otherwise force a + /// parallel edge, so `graph` is always simple (no parallel edges, no + /// self-loops). + graph: UnGraph, + + /// `C(v)`, indexed by `NodeIndex::index()`. + colours: Vec, + + /// `C_eq(v)`: the set of (0-based) equation indices whose right-hand + /// side reaches this vertex, indexed by `NodeIndex::index()`. + equations: Vec>, + + /// The unified parameter vector; `parameters[k]` is the vertex + /// `NodeIndex(k)`. + parameters: Vec, + + /// Names of the bound predicate variables, in equation order, for + /// diagnostics only. + equation_names: Vec, +} + +impl Sdg { + /// Returns the number of parameters (the size of the permutation domain + /// that symmetries are ultimately expressed over). + pub fn num_parameters(&self) -> usize { + self.parameters.len() + } + + /// Returns the number of vertices in the graph. + pub fn num_vertices(&self) -> usize { + self.graph.node_count() + } + + /// Returns the number of edges in the graph. + pub fn num_edges(&self) -> usize { + self.graph.edge_count() + } +} + +/// Returns the shared parameter vector after unification. Panics if equations +/// disagree, which cannot happen because [`build_sdg`] calls +/// `SrfPbes::unify_parameters` before reaching this point. +fn unified_parameters(equations: &mcrl2::PbesEquations) -> Result, MercError> { + let Some(first) = equations.first() else { + return Ok(Vec::new()); + }; + + let parameters: Vec = first.variable().parameters().iter().collect(); + for equation in equations.iter().skip(1) { + let other: Vec = equation.variable().parameters().iter().collect(); + if other != parameters { + return Err(format!( + "Equation for '{}' does not declare the same parameter vector as equation for '{}'; \ + the symmetry detection graph currently requires every equation to share an \ + identical (name, sort)-ordered parameter vector.\n {}: [{}]\n {}: [{}]", + equation.variable().name(), + first.variable().name(), + first.variable().name(), + parameters.iter().format(", "), + equation.variable().name(), + other.iter().format(", "), + ) + .into()); + } + } + + Ok(parameters) +} + +/// Builds the symmetry detection graph of `pbes`. +/// +/// Every equation must already share the same parameter vector; call +/// [`graph_symmetries`] (which calls [`Pbes::unify_parameters`] first) when +/// that precondition is not yet established. +pub fn build_sdg(pbes: &Pbes) -> Result { + let equations = pbes.equations(); + let parameters = unified_parameters(&equations)?; + + let mut builder = SdgBuilder::new(); + + // Allocate the parameter vertices first, so `NodeIndex(k) == parameter + // k`. This is what makes "restrict an automorphism to the parameter + // vertices" trivial: GAP point `k+1` <-> parameter `k`. + for (k, parameter) in parameters.iter().enumerate() { + let term = PbesExpression::new(parameter.clone().into()); + let colour = VertexColour::Parameter(parameter.sort().protect()); + let index = builder.add_vertex(SdgVertex::Parameter(k), colour); + builder.term_map.insert(term, index); + } + debug_assert_eq!(builder.graph.node_count(), parameters.len()); + + let n = parameters.len(); + debug!("SDG build: {} parameter(s): [{}]", n, parameters.iter().format(", ")); + + for (e, equation) in equations.iter().enumerate() { + let formula = equation.formula(); + builder.visit(formula.copy(), e)?; + + // Deduplicate PVIs by ATerm identity: `Y(n) && Y(n)` yields two occurrences + // from the traversal but they share one vertex, so one set of update vertices suffices. + debug!( + "SDG build: equation {} '{}' — {} vertices so far", + e, + equation.variable().name(), + builder.graph.node_count() + ); + + let mut seen_pvis: HashSet = HashSet::new(); + for pvi in pbes_expression_pvi(&formula.copy()) { + if seen_pvis.insert(pvi.clone()) { + builder.add_update_vertices(e, &pvi, n)?; + } + } + } + + // Postconditions. + debug_assert_eq!(builder.colours.len(), builder.equations.len()); + debug_assert_eq!(builder.colours.len(), builder.graph.node_count()); + for k in 0..n { + debug_assert!( + matches!(builder.colours[k], VertexColour::Parameter(_)), + "the first n vertices must be exactly the parameter vertices" + ); + } + for (index, colour) in builder.colours.iter().enumerate().skip(n) { + debug_assert!( + !matches!(colour, VertexColour::Parameter(_)), + "no vertex beyond the first n may be coloured Parameter (found at index {index})" + ); + } + for node in builder.graph.node_indices() { + debug_assert!( + builder.graph.find_edge(node, node).is_none(), + "the SDG must not contain self-loops" + ); + } + + Ok(Sdg { + graph: builder.graph, + colours: builder.colours, + equations: builder.equations, + parameters, + equation_names: equations.iter().map(|eq| eq.variable().name().to_string()).collect(), + }) +} + +/// Builds up an [`Sdg`] incrementally by walking a PBES's right-hand sides. +struct SdgBuilder { + /// The graph under construction; becomes [`Sdg::graph`]. + graph: UnGraph, + + /// `C(v)` per vertex, indexed by `NodeIndex::index()`. + colours: Vec, + + /// `C_eq(v)` per vertex, indexed by `NodeIndex::index()`. + equations: Vec>, + + /// Deduplicates [`SdgVertex::Term`] vertices by (maximally shared) term + /// identity -- see [`SdgVertex::Term`]. + term_map: HashMap, + + /// Bound (quantifier-/abstraction-scoped) variables currently in scope, + /// innermost last, used to tell a bound-variable occurrence apart from a + /// PBES parameter of the same name (see [`VertexColour::BoundVariable`]). + scope: Vec, +} + +impl SdgBuilder { + fn new() -> Self { + SdgBuilder { + graph: UnGraph::new_undirected(), + colours: Vec::new(), + equations: Vec::new(), + term_map: HashMap::new(), + scope: Vec::new(), + } + } + + /// Adds a fresh vertex, keeping `colours`/`equations` in lockstep with + /// `graph`'s node indices. + fn add_vertex(&mut self, vertex: SdgVertex, colour: VertexColour) -> NodeIndex { + let index = self.graph.add_node(vertex); + debug_assert_eq!(index.index(), self.colours.len()); + self.colours.push(colour); + self.equations.push(BTreeSet::new()); + index + } + + fn mark_equation(&mut self, node: NodeIndex, equation: usize) { + self.equations[node.index()].insert(equation); + } + + /// Adds an edge `(u, v, colour)`, merging into an already-existing `(u, + /// v)` edge's label set instead of inserting a parallel edge if one is + /// already present. See [`EdgeColour`]. + fn add_or_merge_edge(&mut self, u: NodeIndex, v: NodeIndex, colour: EdgeColour) { + if let Some(edge) = self.graph.find_edge(u, v) { + let existing = self + .graph + .edge_weight_mut(edge) + .expect("find_edge returned a valid edge index"); + trace!( + "edge: merge v{}--v{} colour {:?} into {:?}", + u.index(), + v.index(), + colour, + existing + ); + *existing = merge_edge_colour(existing.clone(), colour); + } else { + trace!("edge: add v{}--v{} colour {:?}", u.index(), v.index(), colour); + self.graph.add_edge(u, v, colour); + } + } + + /// Pushes `variables` onto the bound-variable scope, returning how many + /// were pushed (for [`Self::pop_scope`]). + fn push_scope(&mut self, variables: I) -> usize + where + I: Iterator, + { + let mut count = 0; + for variable in variables { + self.scope.push(variable); + count += 1; + } + count + } + + fn pop_scope(&mut self, count: usize) { + self.scope.truncate(self.scope.len() - count); + } + + /// Interns a vertex for `term` (deduplicated by term identity), marks it with `equation`, and + /// recurses into children. Hand-rolled because `NodeIndex` must flow upward and argument + /// positions vary per child — constraints the visitor traits cannot express. + fn visit(&mut self, term: PbesExpressionRef<'_>, equation: usize) -> Result { + let key = term.protect(); + if let Some(&node) = self.term_map.get(&key) { + trace!("visit: reused v{} for `{}` (eq {})", node.index(), term, equation); + self.mark_equation(node, equation); + return Ok(node); + } + + let colour = self.colour_of(&key)?; + let node = self.add_vertex(SdgVertex::Term(key.clone()), colour.clone()); + trace!( + "visit: new v{} {:?} for `{}` (eq {})", + node.index(), + colour, + term, + equation + ); + self.term_map.insert(key.clone(), node); + self.mark_equation(node, equation); + + self.visit_children(&key, node, equation)?; + Ok(node) + } + + /// Visits `child`, then adds edge `(parent, child, colour)`. + fn visit_child( + &mut self, + parent: NodeIndex, + child: PbesExpressionRef<'_>, + colour: EdgeColour, + equation: usize, + ) -> Result<(), MercError> { + let child_node = self.visit(child, equation)?; + self.add_or_merge_edge(parent, child_node, colour); + Ok(()) + } + + /// Determines `C(term)`. Does not recurse; see [`Self::visit_children`] + /// for the edges to `term`'s children. + fn colour_of(&self, term: &PbesExpression) -> Result { + let r: ATermRef<'_> = term.copy().into(); + + if is_pbes_and(&r) { + Ok(VertexColour::Connective(Connective::And)) + } else if is_pbes_or(&r) { + Ok(VertexColour::Connective(Connective::Or)) + } else if is_pbes_not(&r) { + Ok(VertexColour::Connective(Connective::Not)) + } else if is_pbes_imp(&r) { + Ok(VertexColour::Connective(Connective::Imp)) + } else if is_pbes_forall(&r) { + let forall = PbesForallRef::from(r); + let sorts = forall.variables().iter().map(|v| v.sort().protect()).collect(); + Ok(VertexColour::Quantifier(Quantifier::Forall, sorts)) + } else if is_pbes_exists(&r) { + let exists = PbesExistsRef::from(r); + let sorts = exists.variables().iter().map(|v| v.sort().protect()).collect(); + Ok(VertexColour::Quantifier(Quantifier::Exists, sorts)) + } else if is_pbes_propositional_variable_instantiation(&r) { + Ok(VertexColour::Pvi) + } else if is_variable(&r) { + let variable = DataVariableRef::from(r); + if self.scope.iter().any(|bound| bound.name() == variable.name()) { + Ok(VertexColour::BoundVariable(variable.sort().protect())) + } else { + Ok(VertexColour::Parameter(variable.sort().protect())) + } + } else if is_application(&r) { + let application = DataApplicationRef::from(r); + Ok(VertexColour::Function( + application.data_function_symbol().name().to_string(), + )) + } else if is_function_symbol(&r) { + let symbol = DataFunctionSymbolRef::from(r); + Ok(VertexColour::Function(symbol.name().to_string())) + } else if is_machine_number(&r) { + let number = DataMachineNumberRef::from(r); + Ok(VertexColour::MachineNumber(number.value())) + } else if is_untyped_identifier(&r) { + // Should not occur in a well-typed PBES; treat as an opaque + // nullary "function" so it at least gets a stable colour. + Ok(VertexColour::Function(format!("{r:?}"))) + } else if is_abstraction(&r) { + // Data-level binder: lambda, forall, exists, set/bag comprehension. + // Colour the same as the PBES-level quantifier for forall/exists so + // that structurally identical sub-formulas remain deduplicated. + let abstraction = DataAbstractionRef::from(r.copy()); + let sorts: Vec = abstraction.variables().iter().map(|v| v.sort().protect()).collect(); + let bo = abstraction.binding_operator(); + let q = if bo.is_forall() { + Quantifier::Forall + } else if bo.is_exists() { + Quantifier::Exists + } else if bo.is_lambda() { + Quantifier::Lambda + } else { + Quantifier::Comprehension + }; + Ok(VertexColour::Quantifier(q, sorts)) + } else if is_where_clause(&r) { + Err(MercError::from( + "where clauses are not supported in the symmetry detection graph construction", + )) + } else { + unreachable!("Unknown PBES/data expression kind for term {:?}", r) + } + } + + /// Adds edges from `node` (the vertex for `term`) to the vertices of its + /// immediate children, recursing via [`Self::visit`]. Mirrors + /// Definition 2's `sub#`, generalized per the module documentation. + fn visit_children(&mut self, term: &PbesExpression, node: NodeIndex, equation: usize) -> Result<(), MercError> { + let r: ATermRef<'_> = term.copy().into(); + + if is_pbes_and(&r) { + let mut leaves = Vec::new(); + collect_pbes_flat(term.copy(), is_pbes_and, &mut leaves); + for leaf in leaves { + self.visit_child(node, leaf.copy(), EdgeColour::Uncoloured, equation)?; + } + } else if is_pbes_or(&r) { + let mut leaves = Vec::new(); + collect_pbes_flat(term.copy(), is_pbes_or, &mut leaves); + for leaf in leaves { + self.visit_child(node, leaf.copy(), EdgeColour::Uncoloured, equation)?; + } + } else if is_pbes_imp(&r) { + // Implication is NOT commutative: lhs => rhs ≠ rhs => lhs. + // Color the edges by position to prevent spurious symmetries. + let imp = PbesImpRef::from(r); + self.visit_child( + node, + imp.lhs(), + EdgeColour::Argument([1].into_iter().collect()), + equation, + )?; + self.visit_child( + node, + imp.rhs(), + EdgeColour::Argument([2].into_iter().collect()), + equation, + )?; + } else if is_pbes_not(&r) { + let not = PbesNotRef::from(r); + self.visit_child(node, not.body(), EdgeColour::Uncoloured, equation)?; + } else if is_pbes_forall(&r) { + let forall = PbesForallRef::from(r); + let pushed = self.push_scope(forall.variables().iter()); + self.visit_child(node, forall.body(), EdgeColour::Uncoloured, equation)?; + self.pop_scope(pushed); + } else if is_pbes_exists(&r) { + let exists = PbesExistsRef::from(r); + let pushed = self.push_scope(exists.variables().iter()); + self.visit_child(node, exists.body(), EdgeColour::Uncoloured, equation)?; + self.pop_scope(pushed); + } else if is_pbes_propositional_variable_instantiation(&r) { + // A PVI is not itself descended into: Definition 3 reaches its + // arguments only through update vertices (see + // `SdgBuilder::add_update_vertices`), matching "phi is not + // itself a PVI" in the edge rule. + } else if is_variable(&r) || is_function_symbol(&r) || is_machine_number(&r) || is_untyped_identifier(&r) { + // Leaves: sub#(x) = {} (also true for a nullary function symbol, + // a machine number, and an untyped identifier). + } else if is_application(&r) { + let application = DataApplicationRef::from(r.copy()); + let head = application.data_function_symbol(); + let arity = application.data_arguments().len(); + + if is_flat_operator(head.name(), arity) { + // Flatten the entire chain into a single n-ary vertex so that + // `a && b && c` (stored as `&&(&&(a,b),c)`) yields one vertex + // with three uncoloured edges rather than nested binary nodes. + let name = head.name(); + let expr: DataExpressionRef<'_> = r.into(); + let leaves = flatten_associative(&expr, |t| { + is_application(t) && DataApplicationRef::from(t.copy()).data_function_symbol().name() == name + }); + for leaf in &leaves { + self.visit_child(node, leaf.copy().into(), EdgeColour::Uncoloured, equation)?; + } + } else { + let commutative = is_commutative(head.name(), arity); + + // Group arguments by the vertex they resolve to, combining + // positions for repeated arguments (see `EdgeColour::Argument`). + let mut by_child: BTreeMap> = BTreeMap::new(); + for (position, argument) in application.data_arguments().enumerate() { + let child = self.visit(argument.into(), equation)?; + by_child.entry(child).or_default().insert(position + 1); + } + + for (child, positions) in by_child { + let colour = if commutative { + EdgeColour::Uncoloured + } else { + EdgeColour::Argument(positions) + }; + self.add_or_merge_edge(node, child, colour); + } + } + } else if is_abstraction(&r) { + let abstraction = DataAbstractionRef::from(r); + let pushed = self.push_scope(abstraction.variables().iter()); + self.visit_child(node, abstraction.body().into(), EdgeColour::Uncoloured, equation)?; + self.pop_scope(pushed); + } else if is_where_clause(&r) { + return Err(MercError::from( + "where clauses are not supported in the symmetry detection graph construction", + )); + } else { + unreachable!("Unknown PBES/data expression kind for term {:?}", r) + } + Ok(()) + } + + /// Adds the update vertices `X_{i,k}` (for `k` in `1..=n`) for a single + /// PVI `pvi` occurring in the right-hand side of `equation`, along with + /// their edges to the PVI vertex, the PVI's argument vertices, and the + /// global parameter vertices. + fn add_update_vertices( + &mut self, + equation: usize, + pvi: &PbesPropositionalVariableInstantiation, + n: usize, + ) -> Result<(), MercError> { + let pvi_expression: PbesExpression = pvi.clone().into(); + // The PVI vertex must already exist: it was interned while walking + // the right-hand side (PVIs are leaves of that walk, but they are + // still visited and given a vertex -- see `visit_children`). + let pvi_node = self.visit(pvi_expression.copy(), equation)?; + + let arguments: Vec = pvi.arguments().iter().map(PbesExpression::from).collect(); + if arguments.len() != n { + return Err(format!( + "Predicate variable instance '{}' has {} argument(s), but the unified parameter \ + vector has {} parameter(s); every predicate variable instance must supply exactly \ + one argument per parameter.", + pvi.name(), + arguments.len(), + n + ) + .into()); + } + + // The update vertex's index within this equation's right-hand side + // (the paper's `i` in `X_{i,k}`): the PVI-index counter used here + // only needs to be unique per `(equation, pvi term)`, since update + // vertices are never deduplicated regardless of its value -- so the + // PVI's own vertex index doubles as a stable, sufficiently unique + // `i`. + let i = pvi_node.index(); + debug!( + "update vertices: eq {} pvi '{}' (v{}) — {} parameter(s)", + equation, + pvi.name(), + i, + n + ); + + for (k, argument) in arguments.iter().enumerate() { + let update_vertex = SdgVertex::Update { + equation, + pvi: i, + parameter: k, + }; + let update_node = self.add_vertex(update_vertex, VertexColour::Update); + trace!( + "update vertex v{} X_({},{},{}) (eq {})", + update_node.index(), + equation, + i, + k, + equation + ); + self.mark_equation(update_node, equation); + + let data_node = self.visit(argument.copy(), equation)?; + let par_node = NodeIndex::new(k); + + // Group the (at most three) targets by vertex identity, and + // colour each resulting edge with the combined set of roles + // that land on it (see `EdgeColour::Update`). + let mut by_target: BTreeMap> = BTreeMap::new(); + by_target.entry(pvi_node).or_default().insert(UpdateRole::Pvi); + by_target.entry(data_node).or_default().insert(UpdateRole::Data); + by_target.entry(par_node).or_default().insert(UpdateRole::Par); + + for (target, roles) in by_target { + self.add_or_merge_edge(update_node, target, EdgeColour::Update(roles)); + } + } + + Ok(()) + } +} + +/// Merges two edge colours that were found to coincide on the same `(u, v)` +/// vertex pair. See [`EdgeColour`] and [`SdgBuilder::add_or_merge_edge`]. +fn merge_edge_colour(a: EdgeColour, b: EdgeColour) -> EdgeColour { + match (a, b) { + (EdgeColour::Argument(mut xs), EdgeColour::Argument(ys)) => { + xs.extend(ys); + EdgeColour::Argument(xs) + } + (EdgeColour::Update(mut xs), EdgeColour::Update(ys)) => { + xs.extend(ys); + EdgeColour::Update(xs) + } + (EdgeColour::Uncoloured, EdgeColour::Uncoloured) => EdgeColour::Uncoloured, + (a, b) => unreachable!( + "Cannot merge incompatible edge colours {:?} and {:?} on the same vertex pair", + a, b + ), + } +} + +/// The SDG in the form GAP's Digraphs package expects: a symmetric digraph +/// (every undirected edge as two opposite directed arcs) with 1-based points. +pub struct GapGraph { + /// `out_neighbours[u]` lists the 1-based out-neighbours of vertex `u+1`. + out_neighbours: Vec>, + /// Positionally aligned with `out_neighbours`; `edge_colours[u][j]` is the + /// dense colour index of the j-th arc out of vertex `u+1`. + edge_colours: Vec>, + /// `vertex_colours[u]` is the dense colour index of vertex `u+1`. + vertex_colours: Vec, + /// How many of the leading vertices (NodeIndex 0..n-1) are parameter vertices. + pub num_parameters: usize, +} + +impl Sdg { + pub fn to_gap_graph(&self) -> GapGraph { + let n = self.graph.node_count(); + let mut vc_map: HashMap = HashMap::new(); + let mut next_vc = 1usize; + let mut vertex_colours = vec![0usize; n]; + + for node in self.graph.node_indices() { + let key = format!("{:?}|{:?}", self.colours[node.index()], self.equations[node.index()]); + let dense = *vc_map.entry(key).or_insert_with(|| { + let c = next_vc; + next_vc += 1; + c + }); + vertex_colours[node.index()] = dense; + } + + let mut ec_map: HashMap = HashMap::new(); + let mut next_ec = 1usize; + let mut out_neighbours = vec![Vec::new(); n]; + let mut edge_colours = vec![Vec::new(); n]; + + for edge in self.graph.edge_indices() { + let (u, v) = self.graph.edge_endpoints(edge).unwrap(); + let colour = self.graph.edge_weight(edge).unwrap(); + let ec_key = format!("{:?}", colour); + let dense_ec = *ec_map.entry(ec_key).or_insert_with(|| { + let c = next_ec; + next_ec += 1; + c + }); + out_neighbours[u.index()].push(v.index() + 1); + edge_colours[u.index()].push(dense_ec); + out_neighbours[v.index()].push(u.index() + 1); + edge_colours[v.index()].push(dense_ec); + } + + debug_assert!( + out_neighbours + .iter() + .zip(&edge_colours) + .all(|(nb, ec)| nb.len() == ec.len()), + "out_neighbours and edge_colours must be positionally aligned" + ); + debug_assert!( + out_neighbours + .iter() + .enumerate() + .all(|(u, nb)| { nb.iter().zip(&edge_colours[u]).collect::>().len() == nb.len() }), + "no two arcs from the same source may share both target and colour" + ); + + GapGraph { + out_neighbours, + edge_colours, + vertex_colours, + num_parameters: self.parameters.len(), + } + } +} + +/// Writes the SDG as a Graphviz DOT file, including vertex/edge colours. +pub fn write_dot(sdg: &Sdg, w: &mut W) -> Result<(), MercError> +where + W: Write, +{ + writeln!(w, "graph sdg {{")?; + writeln!(w, " node [style=filled];")?; + + for node in sdg.graph.node_indices() { + let i = node.index(); + let vc = &sdg.colours[i]; + + let label = match vc { + VertexColour::Parameter(_) => sdg.parameters[i].name().to_string(), + // Update nodes are unlabeled; shape + dashed edges identify them. + VertexColour::Update => String::new(), + VertexColour::Pvi => { + if let SdgVertex::Term(expression) = &sdg.graph[node] { + PbesPropositionalVariableInstantiationRef::from(expression.copy()) + .name() + .to_string() + } else { + unreachable!() + } + } + VertexColour::BoundVariable(_) => { + if let SdgVertex::Term(expression) = &sdg.graph[node] { + let r: ATermRef<'_> = expression.copy().into(); + DataVariableRef::from(r).name().to_string() + } else { + unreachable!() + } + } + VertexColour::Quantifier(q, _) => { + if let SdgVertex::Term(expression) = &sdg.graph[node] { + let r: ATermRef<'_> = expression.copy().into(); + let vars: Vec = if is_pbes_forall(&r) { + PbesForallRef::from(r) + .variables() + .iter() + .map(|v| format!("{}:{}", v.name(), v.sort().pretty_print())) + .collect() + } else if is_pbes_exists(&r) { + PbesExistsRef::from(r) + .variables() + .iter() + .map(|v| format!("{}:{}", v.name(), v.sort().pretty_print())) + .collect() + } else { + DataAbstractionRef::from(r) + .variables() + .iter() + .map(|v| format!("{}:{}", v.name(), v.sort().pretty_print())) + .collect() + }; + format!("{q} {}", vars.join(",")) + } else { + unreachable!() + } + } + _ => vc.to_string(), + }; + + let shape = match vc { + VertexColour::Parameter(_) => "box", + VertexColour::Update => "diamond", + VertexColour::Pvi => "hexagon", + VertexColour::Quantifier(..) => "parallelogram", + _ => "ellipse", + }; + + let fill = vc.dot_fill_colour(); + if matches!(vc, VertexColour::Update) { + writeln!( + w, + " n{i} [label=\"\", shape=diamond, fillcolor=\"{fill}\", width=0.2, height=0.2, fixedsize=true];" + )?; + } else { + writeln!(w, " n{i} [label=\"{label}\", shape={shape}, fillcolor=\"{fill}\"];")?; + } + } + + for edge in sdg.graph.edge_indices() { + let (u, v) = sdg.graph.edge_endpoints(edge).unwrap(); + let colour = sdg.graph.edge_weight(edge).unwrap(); + let elabel = colour.to_string(); + + if matches!(colour, EdgeColour::Update(_)) { + if elabel.is_empty() { + writeln!(w, " n{} -- n{} [style=dashed];", u.index(), v.index())?; + } else { + writeln!( + w, + " n{} -- n{} [style=dashed, label=\"{elabel}\"];", + u.index(), + v.index() + )?; + } + } else if elabel.is_empty() { + writeln!(w, " n{} -- n{};", u.index(), v.index())?; + } else { + writeln!(w, " n{} -- n{} [label=\"{elabel}\"];", u.index(), v.index())?; + } + } + + writeln!(w, "}}")?; + Ok(()) +} + +/// petgraph only implements the 4-byte N(n) encoding; the format supports up to 68_719_476_735. +const GRAPH6_MAX_NODES: usize = 258_047; + +/// Renders the structural skeleton of the SDG in graph6 format. +/// +/// graph6 encodes only the adjacency structure of a simple undirected graph; +/// vertex and edge colours are not representable. Use this as a debug or +/// interop artifact (nauty's `showg`, GAP's `DigraphFromGraph6String`), not +/// as the channel through which colours reach GAP (that goes through the script). +pub fn graph6_string(sdg: &Sdg) -> Result { + if sdg.graph.node_count() > GRAPH6_MAX_NODES { + return Err( + format!("petgraph's graph6 encoder does not support graphs over {GRAPH6_MAX_NODES} vertices").into(), + ); + } + Ok(sdg.graph.graph6_string()) +} + +/// Generates a self-contained GAP script that computes `Aut(G)` via the +/// Digraphs package, restricts generators to the parameter vertices, and +/// prints the result between `SDG-BEGIN`/`SDG-END` sentinels. +/// +/// Every statement ends in `;;` because a script fed on stdin is read as an +/// interactive session where a single `;` echoes the value to stdout. +/// The sentinels also defend against GAP's exit-0-on-syntax-error behaviour. +fn gap_script(graph: &GapGraph) -> String { + let n = graph.num_parameters; + let num_vertices = graph.out_neighbours.len(); + + let neighbours_str = (0..num_vertices) + .map(|u| { + if graph.out_neighbours[u].is_empty() { + "[]".to_string() + } else { + format!("[{}]", graph.out_neighbours[u].iter().join(",")) + } + }) + .join(","); + + let vc_str = graph.vertex_colours.iter().join(","); + + let ec_str = (0..num_vertices) + .map(|u| { + if graph.edge_colours[u].is_empty() { + "[]".to_string() + } else { + format!("[{}]", graph.edge_colours[u].iter().join(",")) + } + }) + .join(","); + + format!( + r#"SetPrintFormattingStatus("*stdout*", false);; +if LoadPackage("digraphs") = fail then + Error("the GAP package 'Digraphs' could not be loaded; install it from https://digraphs.github.io/Digraphs/"); +fi;; +D := Digraph([{neighbours_str}]);; +vcolours := [{vc_str}];; +ecolours := [{ec_str}];; +A := AutomorphismGroup(D, vcolours, ecolours);; +R := List(GeneratorsOfGroup(A), g -> RestrictedPerm(g, [1..{n}]));; +P := GroupByGenerators(R, ());; +Print("SDG-BEGIN\n");; +Print("order ", Size(A), "\n");; +Print("restricted ", Size(P), "\n");; +for g in GeneratorsOfGroup(P) do + for i in [1..{n}] do Print(i^g, " "); od; + Print("\n"); +od;; +Print("SDG-END\n");; +QUIT;; +"# + ) +} + +/// Configuration for invoking the external GAP process. +pub struct GapConfig { + /// Path or name of the GAP executable (default: `"gap"` on `$PATH`). + pub executable: String, + /// If set, the generated GAP script is also written to this file. + pub dump_script: Option, +} + +impl Default for GapConfig { + fn default() -> Self { + GapConfig { + executable: "gap".to_string(), + dump_script: None, + } + } +} + +/// Invokes GAP with the generated script on stdin and returns the captured stdout. +/// +/// Flags used (verified on GAP 4.12.1): +/// - `-q` suppresses the banner and prompt +/// - `-A` disables autoloading of suggested packages +/// - `-r` ignores the user's gap.ini +/// - `--quitonbreak` makes runtime errors exit with non-zero status +/// (note: do NOT add `-T`/`--nobreakloop`, which defeats `--quitonbreak`) +pub fn run_gap(script: &str, config: &GapConfig) -> Result { + if let Some(path) = &config.dump_script { + fs::write(path, script) + .map_err(|e| MercError::from(format!("failed to write GAP script to '{}': {}", path.display(), e)))?; + } + + let output = duct::cmd(&*config.executable, ["-q", "-A", "-r", "--quitonbreak"]) + .stdin_bytes(script.to_owned()) + .stdout_capture() + .stderr_capture() + .unchecked() + .run() + .map_err(|e| { + if e.kind() == ErrorKind::NotFound { + MercError::from(format!( + "GAP executable '{}' not found; install GAP from https://www.gap-system.org/ \ + or pass --gap-path to specify its location", + config.executable + )) + } else { + MercError::from(e) + } + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let mut msg = format!("GAP exited with {}", output.status); + if stderr.contains("digraphs") || stderr.contains("Digraphs") { + msg.push_str( + "; the Digraphs package may not be installed — \ + see https://digraphs.github.io/Digraphs/", + ); + } else if !stderr.trim().is_empty() { + msg.push_str(&format!(": {}", stderr.trim())); + } + return Err(msg.into()); + } + + String::from_utf8(output.stdout).map_err(|e| MercError::from(e.to_string())) +} + +/// Parses the output produced by the GAP script between the `SDG-BEGIN`/`SDG-END` +/// sentinels back into `(automorphism_group_order, symmetry_group_order, generators)`. +/// +/// GAP prints permutations as 1-indexed image vectors; this function converts +/// them to 0-indexed and builds [`Permutation`] values via [`Permutation::from_mapping`]. +fn parse_gap_output(stdout: &str, num_parameters: usize) -> Result<(u128, u128, Vec), MercError> { + // Extract lines strictly between the sentinels. + let begin_pos = stdout.find("SDG-BEGIN").ok_or_else(|| { + MercError::from("GAP output is missing 'SDG-BEGIN' sentinel — check for syntax errors in the generated script") + })?; + let after_begin = &stdout[begin_pos + "SDG-BEGIN".len()..]; + let end_pos = after_begin + .find("SDG-END") + .ok_or_else(|| MercError::from("GAP output is missing 'SDG-END' sentinel"))?; + let inner = after_begin[..end_pos].trim(); + + let mut lines = inner.lines(); + + let aut_order: u128 = lines + .next() + .and_then(|l| l.strip_prefix("order ")) + .and_then(|s| s.trim().parse().ok()) + .ok_or_else(|| MercError::from("expected 'order ' as first line inside sentinels"))?; + + let sym_order: u128 = lines + .next() + .and_then(|l| l.strip_prefix("restricted ")) + .and_then(|s| s.trim().parse().ok()) + .ok_or_else(|| MercError::from("expected 'restricted ' as second line inside sentinels"))?; + + let mut generators = Vec::new(); + for (line_no, line) in lines.enumerate() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let images: Vec = line + .split_whitespace() + .map(|s| { + let v = s + .parse::() + .map_err(|_| MercError::from(format!("invalid image '{}' on generator line {}", s, line_no)))?; + v.checked_sub(1).ok_or_else(|| { + MercError::from(format!( + "invalid image '{}' on generator line {} (expected >= 1)", + s, line_no + )) + }) + }) + .collect::>()?; + + if images.len() != num_parameters { + return Err(format!( + "generator line {} has {} images but expected {} (num_parameters)", + line_no, + images.len(), + num_parameters + ) + .into()); + } + + let mapping: Vec<(usize, usize)> = images.into_iter().enumerate().filter(|(from, to)| from != to).collect(); + + if !mapping.is_empty() { + generators.push(Permutation::from_mapping(mapping)); + } + } + + Ok((aut_order, sym_order, generators)) +} + +/// Result returned by [`graph_symmetries`]. +pub struct GraphSymmetryResult { + /// The symmetry detection graph the automorphisms were computed on. + pub sdg: Sdg, + + /// `|Aut(G)|`, the order of the automorphism group of the whole SDG. + pub automorphism_group_order: u128, + + /// `|Sym(pbes)|`, the order after restricting to the parameter vertices. + pub symmetry_group_order: u128, + + /// Generators of `Sym(pbes)`, as permutations of the parameter indices. + pub generators: Vec, +} + +/// Constructs the "symmetry detection graph" (SDG) of a PBES, and uses it (via +/// the GAP automorphism-group computation, see [`run_gap`]) to derive +/// permutation symmetries of the PBES's parameters using auto morphisms of the +/// SDG. +pub fn graph_symmetries(pbes: &Pbes, config: &GapConfig) -> Result { + // Unify on a copy so build_sdg works on a PBES with one parameter vector + // while the caller keeps the equations it passed in. + let mut pbes = pbes.clone(); + pbes.unify_parameters(UNIFY_IGNORE_CE_EQUATIONS, UNIFY_RESET_PARAMETERS)?; + let sdg = build_sdg(&pbes)?; + info!( + "SDG: {} vertices, {} edges, {} parameters", + sdg.num_vertices(), + sdg.num_edges(), + sdg.num_parameters() + ); + debug!("SDG equation names: [{}]", sdg.equation_names.iter().format(", ")); + + let gap_graph = sdg.to_gap_graph(); + let script = gap_script(&gap_graph); + let stdout = run_gap(&script, config)?; + let (aut_order, sym_order, generators) = parse_gap_output(&stdout, gap_graph.num_parameters)?; + + info!( + "|Aut(G)| = {}, |Sym(pbes)| = {}, {} generator(s)", + aut_order, + sym_order, + generators.len() + ); + + Ok(GraphSymmetryResult { + sdg, + automorphism_group_order: aut_order, + symmetry_group_order: sym_order, + generators, + }) +} + +#[cfg(test)] +mod tests { + use mcrl2::Pbes; + use merc_utilities::test_logger; + use petgraph::graph::NodeIndex; + use petgraph::visit::EdgeRef; + use std::sync::OnceLock; + + use test_case::test_case; + + use super::EdgeColour; + use super::GapConfig; + use super::GraphSymmetryResult; + use super::Quantifier; + use super::UpdateRole; + use super::VertexColour; + use super::build_sdg; + use super::graph_symmetries; + + /// Returns `true` when GAP with the Digraphs package is usable. + /// Cached so the probe script runs at most once per test process. + fn gap_with_digraphs_available() -> bool { + static AVAILABLE: OnceLock = OnceLock::new(); + *AVAILABLE.get_or_init(|| { + duct::cmd("gap", ["-q", "-A", "-r", "--quitonbreak"]) + .stdin_bytes("if LoadPackage(\"digraphs\") = fail then QUIT_GAP(1); fi;; QUIT_GAP(0);;") + .stdout_null() + .stderr_null() + .unchecked() + .run() + .map(|o| o.status.success()) + .unwrap_or(false) + }) + } + + /// Runs `graph_symmetries` on the given PBES source, skipping if GAP or + /// Digraphs is unavailable. + fn check_gap_symmetries(source: &str) -> Option { + if !gap_with_digraphs_available() { + return None; + } + let pbes = mcrl2::Pbes::from_text(source).unwrap(); + Some(graph_symmetries(&pbes, &GapConfig::default()).unwrap()) + } + + #[test_case(include_str!("../../../../../examples/pbes/a.text.pbes"); "a")] + #[test_case(include_str!("../../../../../examples/pbes/b.text.pbes"); "b")] + #[test_case(include_str!("../../../../../examples/pbes/c.text.pbes"); "c")] + #[test_case(include_str!("../../../../../examples/pbes/alloc3.text.pbes"); "alloc3")] + #[test_case(include_str!("../../../../../examples/pbes/alloc7.text.pbes"); "alloc7")] + #[test_case(include_str!("../../../../../examples/pbes/alloc9.text.pbes"); "alloc9")] + #[test_case(include_str!("../../../../../examples/pbes/dining8.text.pbes"); "dining8")] + fn test_gap_symmetries(source: &str) { + check_gap_symmetries(source); + } + + /// Checks the graph structure of the SDG built from `c.text.pbes`, which + /// has 4 parameters. + #[test] + fn test_c_pbes_parameter_vertices() { + test_logger(); + let pbes = Pbes::from_text(include_str!("../../../../../examples/pbes/c.text.pbes")).unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + assert_eq!(sdg.num_parameters(), 4, "c.text.pbes has 4 parameters"); + for k in 0..4 { + assert!( + matches!(sdg.colours[k], VertexColour::Parameter(_)), + "vertex {k} should be the k'th parameter" + ); + } + // No other vertex may be coloured Parameter. + assert!( + sdg.colours + .iter() + .skip(4) + .all(|c| !matches!(c, VertexColour::Parameter(_))) + ); + + assert!(sdg.num_vertices() > 4, "there should be vertices beyond the parameters"); + assert!(sdg.num_edges() > 0); + } + + #[test] + fn test_a_and_b_pbes_build_without_error() { + test_logger(); + for source in [ + include_str!("../../../../../examples/pbes/a.text.pbes"), + include_str!("../../../../../examples/pbes/b.text.pbes"), + ] { + let pbes = Pbes::from_text(source).unwrap(); + build_sdg(&pbes).unwrap(); + } + } + + /// Identical subterms across two different equations collapse to a + /// single vertex. + #[test] + fn test_identical_subterms_share_one_vertex_across_equations() { + test_logger(); + let pbes = Pbes::from_text( + "pbes mu X(n: Nat) = val(n == 0) && Y(n); + mu Y(n: Nat) = val(n == 0) && X(n); + init X(0);", + ) + .unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + // `n == 0` occurs (syntactically identically) in both equations, so + // it must be exactly one vertex, reachable from both equation 0 (X) + // and equation 1 (Y). + let condition_node = sdg + .graph + .node_indices() + .find(|&index| matches!(&sdg.colours[index.index()], VertexColour::Function(name) if name == "==")) + .expect("there should be a vertex for the '==' application"); + assert_eq!( + sdg.equations[condition_node.index()], + [0, 1].into_iter().collect(), + "the shared condition must be reachable from both equations" + ); + } + + /// Update vertices are never deduplicated, even when two `(equation, + /// pvi, parameter)` triples would otherwise look structurally identical. + #[test] + fn test_update_vertices_are_never_deduplicated() { + test_logger(); + let pbes = Pbes::from_text( + "pbes mu X(n: Nat) = Y(n) && Y(n); + mu Y(n: Nat) = val(n == 0); + init X(0);", + ) + .unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + // Two distinct PVI occurrences `Y(n)` in X's right-hand side, each + // contributing its own update vertex X_{i,0}, even though both + // PVIs are syntactically identical. + let update_count = sdg + .graph + .node_indices() + .filter(|&index| sdg.colours[index.index()] == VertexColour::Update) + .count(); + assert_eq!(update_count, 1, "one PVI occurrence (post-dedup) times one parameter"); + } + + /// The head function symbol of an application is a *colour*, not a vertex: + /// `n + n` should produce exactly two vertices, not three. + #[test] + fn test_head_function_symbol_is_not_a_vertex() { + test_logger(); + let pbes = Pbes::from_text("pbes mu X(n: Nat) = val(n + n == n); init X(0);").unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + let function_names: Vec<&String> = sdg + .colours + .iter() + .filter_map(|colour| match colour { + VertexColour::Function(name) => Some(name), + _ => None, + }) + .collect(); + + // "+" and "==" should both appear as vertex colours. + assert!(function_names.iter().any(|name| name.as_str() == "+")); + assert!(function_names.iter().any(|name| name.as_str() == "==")); + } + + /// A non-commutative function applied twice to the *same* argument + /// (`n - n`) produces exactly one edge to `n`, coloured with the + /// combined position set `{1,2}`. + #[test] + fn test_noncommutative_repeated_argument_gets_combined_label() { + test_logger(); + let pbes = Pbes::from_text("pbes mu X(n: Int) = val(n - n == 0); init X(0);").unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + let minus_node = sdg + .graph + .node_indices() + .find(|&index| matches!(&sdg.colours[index.index()], VertexColour::Function(name) if name == "-")) + .expect("there should be a vertex for the '-' application"); + + // Use edges_connecting to count only edges between minus_node and the parameter `n` + // (NodeIndex(0)), not all incident edges (which would include the parent `==` edge). + let n_node = NodeIndex::new(0); + let edges: Vec<_> = sdg.graph.edges_connecting(minus_node, n_node).collect(); + assert_eq!(edges.len(), 1, "n - n should have exactly one edge to 'n'"); + assert_eq!(*edges[0].weight(), EdgeColour::Argument([1, 2].into_iter().collect())); + } + + /// A commutative function applied twice to the same argument (`n == + /// n`) still produces exactly one (uncoloured) edge. + #[test] + fn test_commutative_repeated_argument_stays_uncoloured() { + test_logger(); + let pbes = Pbes::from_text("pbes mu X(n: Nat) = val(n == n); init X(0);").unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + let eq_node = sdg + .graph + .node_indices() + .find(|&index| matches!(&sdg.colours[index.index()], VertexColour::Function(name) if name == "==")) + .expect("there should be a vertex for the '==' application"); + + let edges: Vec<_> = sdg.graph.edges(eq_node).collect(); + assert_eq!(edges.len(), 1, "n == n should reach 'n' via exactly one edge"); + assert_eq!(*edges[0].weight(), EdgeColour::Uncoloured); + } + + /// A PVI that copies a parameter unchanged (`X(n)` from within `X`'s own + /// right-hand side) makes the `data(X,i,k)` and `d_k` update-edge + /// targets coincide: the update vertex must have exactly one combined + /// `{Data, Par}`-coloured edge to `n`, plus a separate `{Pvi}`-coloured + /// edge to the PVI vertex -- not three edges, and not a silently + /// dropped role. + #[test] + fn test_update_edge_combines_roles_on_parameter_copy() { + test_logger(); + let pbes = Pbes::from_text("pbes mu X(n: Nat) = X(n); init X(0);").unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + let update_node = sdg + .graph + .node_indices() + .find(|&index| sdg.colours[index.index()] == VertexColour::Update) + .expect("there should be exactly one update vertex"); + + let mut edges: Vec<_> = sdg.graph.edges(update_node).map(|e| e.weight().clone()).collect(); + edges.sort_by_key(|colour| format!("{colour:?}")); + + assert_eq!( + edges.len(), + 2, + "expected one {{Pvi}} edge and one combined {{Data,Par}} edge" + ); + assert!(edges.contains(&EdgeColour::Update([UpdateRole::Pvi].into_iter().collect()))); + assert!(edges.contains(&EdgeColour::Update( + [UpdateRole::Data, UpdateRole::Par].into_iter().collect() + ))); + + // And the combined edge's target must be the parameter vertex `n`, + // i.e. NodeIndex(0). + let combined_target = sdg + .graph + .edges(update_node) + .find(|e| *e.weight() == EdgeColour::Update([UpdateRole::Data, UpdateRole::Par].into_iter().collect())) + .map(|e| e.target()) + .unwrap(); + assert_eq!(combined_target, NodeIndex::new(0)); + } + + /// Two equations declaring different parameter vectors are rejected + /// with a clear error rather than silently producing a malformed graph. + #[test] + fn test_rejects_non_uniform_parameter_vectors() { + test_logger(); + // Use the same arity but different parameter names so mCRL2 accepts the PBES + // while `unified_parameters` still rejects it (n:Nat ≠ m:Nat as ATerms). + let pbes = Pbes::from_text( + "pbes mu X(n: Nat) = Y(n); + mu Y(m: Nat) = val(true); + init X(0);", + ) + .unwrap(); + + let result = build_sdg(&pbes); + assert!(result.is_err(), "differing parameter vectors must be rejected"); + } + + /// A bound (quantifier-scoped) variable must not be coloured the same + /// as a PBES parameter, even when it shares a name with one -- otherwise + /// an automorphism could conflate the two. + #[test] + fn test_bound_variable_is_not_coloured_as_parameter() { + test_logger(); + // Use a different name for the bound variable so it is a distinct ATerm from the + // parameter `n`. Under mCRL2 hash-consing, `n:Nat` and `n:Nat` are the same ATerm + // and would therefore collapse to one vertex regardless of scope. + let pbes = Pbes::from_text("pbes mu X(n: Nat) = exists m: Nat . val(n == m); init X(0);").unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + let parameter_count = sdg + .colours + .iter() + .filter(|c| matches!(c, VertexColour::Parameter(_))) + .count(); + assert_eq!(parameter_count, 1); + + let has_bound_variable = sdg.colours.iter().any(|c| matches!(c, VertexColour::BoundVariable(_))); + assert!( + has_bound_variable, + "the quantifier-bound 'm' should be coloured BoundVariable" + ); + } + + /// Parameters of different sorts must never be interchangeable. + /// + /// Function symbols are coloured by name only (`==` exists at every sort), so + /// without the sort in the parameter colour these two parameters have + /// isomorphic neighbourhoods and GAP reports a `Bool` <-> `Nat` swap. Applying + /// it would hand `set_assignments` a value of the wrong sort. + #[test] + fn test_parameters_of_different_sorts_are_not_interchangeable() { + test_logger(); + let pbes = Pbes::from_text("pbes nu X(b: Bool, n: Nat) = X(b, n); init X(true, 0);").unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + assert_ne!( + sdg.colours[0], sdg.colours[1], + "a Bool and a Nat parameter must get different colours" + ); + } + + /// A data-level binder expression (e.g. `val(exists m:Nat. n==m)`) must be + /// assigned a `Quantifier` colour and its bound variable must be coloured + /// `BoundVariable`, not `Parameter`. + #[test] + fn test_data_level_binder_does_not_panic() { + test_logger(); + // `val(exists m: Nat . n == m)` wraps a data-level binder inside val(...) + // so the ATerm reaching colour_of is `Binder(Exists, [m:Nat], ==(n,m))`. + let pbes = Pbes::from_text("pbes mu X(n: Nat) = val(exists m: Nat . n == m); init X(0);").unwrap(); + let sdg = build_sdg(&pbes).unwrap(); + + let has_quantifier = sdg + .colours + .iter() + .any(|c| matches!(c, VertexColour::Quantifier(Quantifier::Exists, _))); + assert!( + has_quantifier, + "data-level exists binder must produce a Quantifier(Exists) vertex" + ); + + let has_bound_variable = sdg.colours.iter().any(|c| matches!(c, VertexColour::BoundVariable(_))); + assert!( + has_bound_variable, + "the data-level bound 'm' must be coloured BoundVariable" + ); + } +} diff --git a/tools/mcrl2/crates/merc_pbes/src/lib.rs b/tools/mcrl2/crates/merc_pbes/src/lib.rs new file mode 100644 index 000000000..92d5b4be8 --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/src/lib.rs @@ -0,0 +1,56 @@ +//! +//! Exploration and symmetry reduction for mCRL2 parameterised boolean equation +//! systems (PBESs). +//! +//! The [`merc-pbes`] binary is a thin command line wrapper around this crate: it +//! parses the arguments, reads the PBES and then calls into the algorithms +//! defined here. +//! +//! - [`explore_pbes()`] and [`explore_pbes_parallel`] instantiate a PBES into a +//! parity game directly via its structure graph, while [`explore_srf_pbes`] +//! and [`explore_srf_pbes_parallel`] first convert it to standard recursive +//! form. [`explore_pbes_symbolic`] performs LDD-based symbolic reachability. +//! - [`graph_symmetries`] detects symmetries by building the symmetry detection +//! graph and handing it to GAP, while [`SymmetryAlgorithm`] checks candidate +//! parameter permutations directly on the equations. +//! - [`Bsgs`] turns a set of generators into a stabilizer chain, which +//! [`QuotientLps`] uses to canonicalize every next-state to its orbit +//! representative during exploration. +//! +//! [`merc-pbes`]: https://mercorg.github.io/merc-website/ + +pub mod bsgs; +pub mod clone_iterator; +pub mod explore_common; +pub mod explore_pbes; +pub mod explore_srf; +pub mod explore_symbolic_srf; +pub mod graph_symmetry; +pub mod permutation; +pub mod quotient_lps; +pub mod symmetry; + +pub use bsgs::Bsgs; +pub use clone_iterator::CloneIterator; +pub use explore_common::ParameterLayoutLPS; +pub use explore_common::PbesVertex; +pub use explore_common::check_parameter_basis; +pub use explore_common::explore_pbes_impl; +pub use explore_common::explore_pbes_parallel_impl; +pub use explore_common::symmetry_parameter_basis; +pub use explore_common::symmetry_unified_pbes; +pub use explore_pbes::PbesLps; +pub use explore_pbes::explore_pbes; +pub use explore_pbes::explore_pbes_parallel; +pub use explore_srf::PbesSrfLps; +pub use explore_srf::explore_srf_pbes; +pub use explore_srf::explore_srf_pbes_parallel; +pub use explore_symbolic_srf::explore_pbes_symbolic; +pub use graph_symmetry::GapConfig; +pub use graph_symmetry::GraphSymmetryResult; +pub use graph_symmetry::Sdg; +pub use graph_symmetry::graph_symmetries; +pub use graph_symmetry::write_dot; +pub use permutation::Permutation; +pub use quotient_lps::QuotientLps; +pub use symmetry::SymmetryAlgorithm; diff --git a/tools/mcrl2/pbes/src/permutation.rs b/tools/mcrl2/crates/merc_pbes/src/permutation.rs similarity index 92% rename from tools/mcrl2/pbes/src/permutation.rs rename to tools/mcrl2/crates/merc_pbes/src/permutation.rs index ab51a7946..cd1b55758 100644 --- a/tools/mcrl2/pbes/src/permutation.rs +++ b/tools/mcrl2/crates/merc_pbes/src/permutation.rs @@ -6,7 +6,7 @@ use std::fmt; use merc_utilities::MercError; #[derive(Clone, PartialEq, Eq)] -pub(crate) struct Permutation { +pub struct Permutation { /// We represent a permutation as an explicit list of (domain -> image) pairs, /// sorted by domain. mapping: Vec<(usize, usize)>, @@ -16,7 +16,7 @@ impl Permutation { /// Create a permutation from a given mapping of (domain -> image) pairs. Internally /// sorts the mapping by domain for a unique representation. The input must be /// a valid permutation (so a bijection). - pub(crate) fn from_mapping(mut mapping: Vec<(usize, usize)>) -> Self { + pub fn from_mapping(mut mapping: Vec<(usize, usize)>) -> Self { debug_assert!( is_valid_permutation(&mapping), "Input mapping is not a valid permutation: {:?}", @@ -39,7 +39,7 @@ impl Permutation { } /// Parse a permutation from a string input of the form "[0->2, 1->0, 2->1]". - pub(crate) fn from_mapping_notation(line: &str) -> Result { + pub fn from_mapping_notation(line: &str) -> Result { // Remove the surrounding brackets if present. let trimmed_input = line.trim(); let input_no_brackets = @@ -89,7 +89,7 @@ impl Permutation { } /// Parse a permutation in cycle notation, e.g., (0 2 1)(3 4). - pub(crate) fn from_cycle_notation(cycle_notation: &str) -> Result { + pub fn from_cycle_notation(cycle_notation: &str) -> Result { let mut mapping: Vec<(usize, usize)> = Vec::new(); // Split the input into cycles by finding all '(...)' groups @@ -134,7 +134,7 @@ impl Permutation { } /// Construct a new permutation by concatenating two (disjoint) permutations. - pub(crate) fn concat(self, other: &Permutation) -> Permutation { + pub fn concat(self, other: &Permutation) -> Permutation { debug_assert!( self.mapping .iter() @@ -149,7 +149,7 @@ impl Permutation { } /// Returns the value of the permutation at the given key. - pub(crate) fn value(&self, key: usize) -> usize { + pub fn value(&self, key: usize) -> usize { for (d, v) in &self.mapping { if *d == key { return *v; @@ -160,18 +160,28 @@ impl Permutation { } /// Returns an iterator over the domain of this permutation. - pub(crate) fn domain(&self) -> impl Iterator + '_ { + pub fn domain(&self) -> impl Iterator + '_ { self.mapping.iter().map(|(d, _)| *d) } /// Check whether this permutation is the identity permutation. - pub(crate) fn is_identity(&self) -> bool { + pub fn is_identity(&self) -> bool { self.mapping.iter().all(|(d, v)| d == v) } + + /// Returns the largest point this permutation mentions, or `None` when it + /// moves nothing. + /// + /// Used to reject a generator whose points fall outside the parameter range + /// before it reaches [`crate::bsgs::DensePermutation`], which silently + /// truncates to the group's degree. + pub fn max_point(&self) -> Option { + self.mapping.iter().map(|&(d, v)| d.max(v)).max() + } } /// Checks whether the mapping represents a valid permutation -pub(crate) fn is_valid_permutation(mapping: &[(usize, usize)]) -> bool { +pub fn is_valid_permutation(mapping: &[(usize, usize)]) -> bool { let mut domain = HashSet::with_capacity(mapping.len()); let mut image = HashSet::with_capacity(mapping.len()); @@ -261,7 +271,7 @@ impl fmt::Debug for Permutation { /// - (3 4) /// - (0 3 4) /// - (0 4 3) -pub(crate) fn permutation_group(indices: Vec) -> impl Iterator + Clone { +pub fn permutation_group(indices: Vec) -> impl Iterator + Clone { let n = indices.len(); // Clone the indices for use in the closure. @@ -275,7 +285,7 @@ pub(crate) fn permutation_group(indices: Vec) -> impl Iterator usize { +pub fn permutation_group_size(n: usize) -> usize { (1..=n).fold(1usize, |acc, factor| acc.saturating_mul(factor)) } diff --git a/tools/mcrl2/crates/merc_pbes/src/quotient_lps.rs b/tools/mcrl2/crates/merc_pbes/src/quotient_lps.rs new file mode 100644 index 000000000..4ce68bfe0 --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/src/quotient_lps.rs @@ -0,0 +1,379 @@ +use std::sync::Arc; + +use merc_explore::LPS; +use merc_explore::StateEffect; +use merc_explore::Summand; +use merc_utilities::MercError; + +use crate::bsgs::Bsgs; +use crate::bsgs::CanonicalizeContext; +use crate::explore_common::ParameterLayoutLPS; + +/// Wraps any `LPS` and canonicalizes every enumerated next-state +/// to the lexicographically smallest orbit representative before passing it to +/// the caller. +/// +/// State vectors are laid out as `[eq_idx_0..eq_idx_{offset-1}, param_0..param_{n-1}]` +/// where only positions `param_offset..` are touched by the group action. Position 0 +/// (the equation index) is never permuted. +/// +/// # Wrapping order +/// +/// When combined with [`merc_explore::CacheLPS`], place the cache *inside* and the +/// quotient *outside*: +/// ```text +/// QuotientLps> +/// ``` +/// This keeps cache keys narrow (raw, un-canonicalized write positions) and avoids +/// forcing the cache to track all parameters as written. +pub struct QuotientLps> { + inner: Arc

, + bsgs: Arc, + summands: Vec>, + param_offset: usize, +} + +/// A single summand of a [`QuotientLps`]. +/// +/// Delegates enumeration to the corresponding inner summand and canonicalizes +/// each next-state before reporting it. +pub struct QuotientSummand> { + index: usize, + inner: Arc

, + bsgs: Arc, + param_offset: usize, + read_positions: Vec, +} + +/// Per-thread enumeration context for a [`QuotientLps`]. +pub struct QuotientContext> { + inner: ::Context, + + /// Working buffers of [`Bsgs::canonicalize_into`], so that canonicalizing a + /// next state costs no allocation. + scratch: CanonicalizeContext, + + /// The canonicalized next state handed to the caller's callback. + canonical: Vec, +} + +// SAFETY: Neither struct has interior mutability of its own (no UnsafeCell / +// raw pointers). All concurrent access is read-only via `&self`. The only +// non-trivially-Sync field is `Arc

`: sharing `&Arc

` across threads only +// requires `P: Sync` (which the bound enforces). The stdlib's conservative +// `impl Sync for Arc` also requires `T: Send` to handle the +// last Arc being dropped on a foreign thread, but `QuotientLps` is not `Send`, +// so that case cannot arise. `Arc` is unconditionally fine because `Bsgs` +// contains only `usize`, `Vec`, and `HashMap` of plain data, all auto-`Sync`. +unsafe impl + Sync> Sync for QuotientLps

{} +unsafe impl + Sync> Sync for QuotientSummand

{} + +impl

QuotientLps

+where + P: ParameterLayoutLPS, +{ + /// Wraps `inner` in a canonicalizing quotient layer. + /// + /// `param_offset` is the first position in the state vector that belongs to + /// the PBES parameters (always `1` for `PbesSrfLps`, where position 0 is the + /// equation index). + pub fn new(inner: P, bsgs: Arc, param_offset: usize) -> Self { + let inner = Arc::new(inner); + + let summands = inner + .summands() + .iter() + .enumerate() + .map(|(i, s)| QuotientSummand { + index: i, + inner: Arc::clone(&inner), + bsgs: Arc::clone(&bsgs), + param_offset, + read_positions: s.read_positions().to_vec(), + }) + .collect(); + + QuotientLps { + inner, + bsgs, + summands, + param_offset, + } + } +} + +impl

LPS for QuotientLps

+where + P: ParameterLayoutLPS, +{ + type Value = usize; + type Label = P::Label; + type StateInfo = P::StateInfo; + type Summand = QuotientSummand

; + + fn initial_state(&self) -> Vec { + self.bsgs.canonicalize(&self.inner.initial_state(), self.param_offset) + } + + fn summands(&self) -> &[Self::Summand] { + &self.summands + } + + fn create_context(&self) -> QuotientContext

{ + QuotientContext { + inner: self.inner.create_context(), + scratch: CanonicalizeContext::default(), + canonical: Vec::new(), + } + } + + fn prepare<'a>(&'a self, context: &mut QuotientContext

, state: &'a [usize]) -> impl Iterator + 'a { + self.inner.prepare(&mut context.inner, state) + } + + fn state_info(&self, state: &[usize], context: &QuotientContext

) -> P::StateInfo { + self.inner.state_info(state, &context.inner) + } +} + +impl

Summand for QuotientSummand

+where + P: ParameterLayoutLPS, +{ + type Value = usize; + type Label = P::Label; + type Context = QuotientContext

; + + fn read_positions(&self) -> &[usize] { + &self.read_positions + } + + fn effect(&self) -> StateEffect<'_> { + // Canonicalization can move a value to any parameter position, and the + // states it passes through unchanged are of other lengths entirely. + StateEffect::Opaque + } + + fn enumerate(&self, context: &mut Self::Context, state: &[usize], mut report: F) -> Result<(), MercError> + where + F: FnMut(&Self::Label, &[usize]) -> Result<(), MercError>, + { + let bsgs = &self.bsgs; + let inner = &*self.inner; + let param_offset = self.param_offset; + + // Destructured so the closure can borrow the canonicalization buffers + // while the inner summand holds its own context. + let QuotientContext { + inner: inner_context, + scratch, + canonical, + } = context; + + self.inner.summands()[self.index].enumerate(inner_context, state, |label, next| { + match inner.parameter_range(next) { + Some(range) => { + debug_assert_eq!( + range.start, param_offset, + "the parameter block must start where the group acts" + ); + debug_assert_eq!(range.len(), bsgs.n, "the group must act on the whole parameter block"); + bsgs.canonicalize_into(next, param_offset, scratch, canonical); + report(label, canonical) + } + // Sinks and subformula vertices carry no data parameters, so the + // group does not act on them; permuting their payload would + // corrupt a priority or an interned formula index. + None => report(label, next), + } + }) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use merc_explore::CacheLPS; + use merc_explore::CachingStrategy; + use merc_explore::ExplorationStrategy; + use merc_utilities::MercError; + use merc_utilities::Timing; + use merc_vpg::PG; + use merc_vpg::solve_zielonka; + + use crate::bsgs::Bsgs; + use crate::explore_common::explore_pbes_impl; + use crate::explore_pbes::PbesLps; + use crate::explore_srf::PbesSrfLps; + use crate::graph_symmetry::GapConfig; + use crate::permutation::Permutation; + + use super::QuotientLps; + + fn gap_config() -> GapConfig { + GapConfig { + executable: "gap".to_string(), + dump_script: None, + } + } + + /// Verifies that wrapping `PbesSrfLps` in `QuotientLps` (with trivial group) + /// produces the same parity game size as the unwrapped LPS. + #[test] + #[cfg_attr(miri, ignore)] + fn quotient_trivial_group_same_game_size() -> Result<(), MercError> { + let pbes_text = r#"pbes +nu X(b: Bool) = X(true); +init X(true);"#; + let pbes = mcrl2::Pbes::from_text(pbes_text)?; + + let lps = PbesSrfLps::new(&pbes)?; + let n = lps.num_params(); + let bsgs = Arc::new(Bsgs::from_generators(&[], n, &gap_config())?); + + let timing = Timing::new(); + let plain_game = explore_pbes_impl(&lps, ExplorationStrategy::Bfs, &timing)?; + + let lps2 = PbesSrfLps::new(&pbes)?; + let qlps = QuotientLps::new(lps2, bsgs, 1); + let quot_game = explore_pbes_impl(&qlps, ExplorationStrategy::Bfs, &timing)?; + + assert_eq!(plain_game.num_of_vertices(), quot_game.num_of_vertices()); + assert_eq!(plain_game.num_of_edges(), quot_game.num_of_edges()); + Ok(()) + } + + /// Verifies that `QuotientLps>` compiles and produces a + /// valid game (wrapping order: cache inside, quotient outside). + #[test] + #[cfg_attr(miri, ignore)] + fn quotient_with_cache_compiles() -> Result<(), MercError> { + let pbes_text = r#"pbes +nu X(b: Bool) = X(true); +init X(true);"#; + let pbes = mcrl2::Pbes::from_text(pbes_text)?; + let lps = PbesSrfLps::new(&pbes)?; + let n = lps.num_params(); + let bsgs = Arc::new(Bsgs::from_generators(&[], n, &gap_config())?); + + let cached = CacheLPS::new(lps, CachingStrategy::Local); + let qlps = QuotientLps::new(cached, bsgs, 1); + + let timing = Timing::new(); + let game = explore_pbes_impl(&qlps, ExplorationStrategy::Bfs, &timing)?; + assert!(game.num_of_vertices() > 0); + Ok(()) + } + + /// A PBES whose two parameters are interchangeable, explored with the general + /// (non-SRF) explorer so the game also contains sink and subformula vertices. + /// + /// Those carry a priority and an interned formula index where a propositional + /// variable instantiation carries parameters, so a quotient that permutes them + /// unconditionally corrupts them — the interned index in particular becomes a + /// formula that does not exist. + const SYMMETRIC_PBES: &str = r#"pbes +nu X(m: Nat, n: Nat) = X(m, n) && (Y(n, m) || Y((m + 1) mod 2, n)); +mu Y(m: Nat, n: Nat) = X(m, n) || Y((n + 1) mod 2, m); +init X(0, 1);"#; + + #[test] + #[cfg_attr(miri, ignore)] + fn quotient_preserves_winner_and_reduces_the_game() -> Result<(), MercError> { + let pbes = mcrl2::Pbes::from_text(SYMMETRIC_PBES)?; + let timing = Timing::new(); + + let plain = explore_pbes_impl(&PbesLps::new(pbes.clone())?, ExplorationStrategy::Bfs, &timing)?; + + let lps = PbesLps::new(pbes)?; + let n = lps.num_params(); + let generators = vec![Permutation::from_cycle_notation("(0 1)")?]; + let bsgs = Arc::new(Bsgs::from_generators(&generators, n, &gap_config())?); + let quotient = explore_pbes_impl(&QuotientLps::new(lps, bsgs, 1), ExplorationStrategy::Bfs, &timing)?; + + assert!( + quotient.num_of_vertices() < plain.num_of_vertices(), + "swapping the two parameters is a symmetry, so the quotient must be smaller \ + (plain {} vertices, quotient {})", + plain.num_of_vertices(), + quotient.num_of_vertices() + ); + + let (plain_solution, _) = solve_zielonka(&plain, false); + let (quotient_solution, _) = solve_zielonka("ient, false); + assert_eq!( + plain_solution[0][0], quotient_solution[0][0], + "the quotient changed the winner of the initial vertex" + ); + Ok(()) + } + + /// The SRF backend must reduce and preserve the winner just like the general + /// one — `--srf --symmetry` layers the quotient over [`PbesSrfLps`]. + /// + /// The two games are not the same size: SRF normalisation rewrites the + /// right-hand sides, so this compares the SRF game against its own quotient + /// rather than against the general explorer's. + #[test] + #[cfg_attr(miri, ignore)] + fn quotient_over_srf_preserves_winner_and_reduces_the_game() -> Result<(), MercError> { + let pbes = mcrl2::Pbes::from_text(SYMMETRIC_PBES)?; + let timing = Timing::new(); + + let plain = explore_pbes_impl(&PbesSrfLps::new(&pbes)?, ExplorationStrategy::Bfs, &timing)?; + + let lps = PbesSrfLps::new(&pbes)?; + assert_eq!( + lps.num_params(), + 2, + "the generator below is a transposition of the whole parameter vector" + ); + // Both parameters have the same sort, so the transposition is `(0 1)` + // whichever of the two orders `unify_parameters` happened to produce. + let generators = vec![Permutation::from_cycle_notation("(0 1)")?]; + let bsgs = Arc::new(Bsgs::from_generators(&generators, lps.num_params(), &gap_config())?); + let quotient = explore_pbes_impl(&QuotientLps::new(lps, bsgs, 1), ExplorationStrategy::Bfs, &timing)?; + + assert!( + quotient.num_of_vertices() < plain.num_of_vertices(), + "swapping the two parameters is a symmetry, so the quotient must be smaller \ + (plain {} vertices, quotient {})", + plain.num_of_vertices(), + quotient.num_of_vertices() + ); + + let (plain_solution, _) = solve_zielonka(&plain, false); + let (quotient_solution, _) = solve_zielonka("ient, false); + assert_eq!( + plain_solution[0][0], quotient_solution[0][0], + "the quotient changed the winner of the initial vertex" + ); + Ok(()) + } + + /// The same reduction must survive a cache layer underneath the quotient. + #[test] + #[cfg_attr(miri, ignore)] + fn quotient_over_cache_agrees_with_quotient_alone() -> Result<(), MercError> { + let pbes = mcrl2::Pbes::from_text(SYMMETRIC_PBES)?; + let generators = vec![Permutation::from_cycle_notation("(0 1)")?]; + let timing = Timing::new(); + + let lps = PbesLps::new(pbes.clone())?; + let n = lps.num_params(); + let bsgs = Arc::new(Bsgs::from_generators(&generators, n, &gap_config())?); + let uncached = explore_pbes_impl( + &QuotientLps::new(lps, Arc::clone(&bsgs), 1), + ExplorationStrategy::Bfs, + &timing, + )?; + + let cached = CacheLPS::new(PbesLps::new(pbes)?, CachingStrategy::Local); + let cached = explore_pbes_impl(&QuotientLps::new(cached, bsgs, 1), ExplorationStrategy::Bfs, &timing)?; + + assert_eq!(uncached.num_of_vertices(), cached.num_of_vertices()); + assert_eq!(uncached.num_of_edges(), cached.num_of_edges()); + Ok(()) + } +} diff --git a/tools/mcrl2/pbes/src/symmetry.rs b/tools/mcrl2/crates/merc_pbes/src/symmetry.rs similarity index 96% rename from tools/mcrl2/pbes/src/symmetry.rs rename to tools/mcrl2/crates/merc_pbes/src/symmetry.rs index 0497b03c2..497bce52e 100644 --- a/tools/mcrl2/pbes/src/symmetry.rs +++ b/tools/mcrl2/crates/merc_pbes/src/symmetry.rs @@ -33,12 +33,14 @@ use merc_io::TimeProgress; use merc_utilities::MercError; use crate::clone_iterator::CloneIterator; +use crate::explore_common::UNIFY_IGNORE_CE_EQUATIONS; +use crate::explore_common::UNIFY_RESET_PARAMETERS; use crate::permutation::Permutation; use crate::permutation::permutation_group; use crate::permutation::permutation_group_size; /// Implements symmetry detection for PBESs. -pub(crate) struct SymmetryAlgorithm { +pub struct SymmetryAlgorithm { /// Needs to be kept alive while the control flow graphs are used. state_graph: PbesStategraph, @@ -58,7 +60,7 @@ pub(crate) struct SymmetryAlgorithm { impl SymmetryAlgorithm { /// Does the required preprocessing to analyse symmetries in the given PBES. - pub(crate) fn new(pbes: &Pbes, print_srf: bool) -> Result { + pub fn new(pbes: &Pbes, print_srf: bool) -> Result { let (srf, parameters, state_graph) = preprocess_symmetry(pbes, print_srf)?; let all_control_flow_parameters = state_graph @@ -85,19 +87,19 @@ impl SymmetryAlgorithm { } /// Returns the SRF PBES after unifying parameters. - pub(crate) fn srf_pbes(&self) -> &SrfPbes { + pub fn srf_pbes(&self) -> &SrfPbes { &self.srf } /// Returns the state graph of the PBES. - pub(crate) fn state_graph(&self) -> &PbesStategraph { + pub fn state_graph(&self) -> &PbesStategraph { &self.state_graph } /// Returns compliant permutations. /// /// See [Self::clique_candidates] for the parameters. - pub(crate) fn candidates( + pub fn candidates( &self, partition_data_sorts: bool, partition_data_updates: bool, @@ -151,7 +153,7 @@ impl SymmetryAlgorithm { } /// Checks whether the given permutation is valid, meaning that control flow parameters are mapped to control flow parameters. - pub(crate) fn is_valid_permutation(&self, pi: &Permutation) -> Result<(), MercError> { + pub fn is_valid_permutation(&self, pi: &Permutation) -> Result<(), MercError> { // Check that all control flow parameters are mapped to control flow parameters. for index in pi.domain() { let mapped_index = pi.value(index); @@ -179,7 +181,7 @@ impl SymmetryAlgorithm { } /// Performs the syntactic check defined as symcheck in the paper. - pub(crate) fn check_symmetry(&self, pi: &Permutation) -> bool { + pub fn check_symmetry(&self, pi: &Permutation) -> bool { for equation in self.srf.equations() { for summand in equation.summands() { let mut matched = false; @@ -214,7 +216,7 @@ impl SymmetryAlgorithm { } /// Determine the cliques in the given control flow graphs. - pub(crate) fn cliques(&self) -> Vec> { + pub fn cliques(&self) -> Vec> { let mut cal_I = Vec::new(); for (i, cfg) in self.state_graph.control_flow_graphs().iter().enumerate() { @@ -674,7 +676,7 @@ fn preprocess_symmetry( print_srf: bool, ) -> Result<(SrfPbes, Vec, PbesStategraph), MercError> { let mut srf = SrfPbes::from(pbes)?; - srf.unify_parameters(false, false)?; + srf.unify_parameters(UNIFY_IGNORE_CE_EQUATIONS, UNIFY_RESET_PARAMETERS)?; if print_srf { info!("==== SRF PBES ===="); info!("{}", srf.to_pbes()); @@ -763,7 +765,7 @@ fn replace_variables_by_omega(expression: &DataExpression) -> DataExpression { const UNDEFINED_VERTEX: usize = usize::MAX; /// Returns the index of the variable that the control flow graph represents. -pub(crate) fn variable_index(cfg: &ControlFlowGraph) -> usize { +pub fn variable_index(cfg: &ControlFlowGraph) -> usize { // Find the first defined index let defined_index = cfg .vertices() @@ -801,7 +803,9 @@ fn apply_permutation(expression: &PbesExpression, parameters: &[DataVariable], p let result = substitute_data_expressions(expression, sigma); let pi = (0..parameters.len()).map(|i| pi.value(i)).collect::>(); - reorder_propositional_variables(&result, &pi) + // `pi` comes from a Permutation over the parameter positions, so it is a + // permutation of 0..parameters.len() by construction. + reorder_propositional_variables(&result, &pi).expect("pi is a permutation of the parameters") } #[cfg(test)] @@ -815,7 +819,7 @@ mod tests { #[test] fn test_symmetry_example_a() { test_logger(); - let pbes = Pbes::from_text(include_str!("../../../../examples/pbes/a.text.pbes")).unwrap(); + let pbes = Pbes::from_text(include_str!("../../../../../examples/pbes/a.text.pbes")).unwrap(); let cliques = SymmetryAlgorithm::new(&pbes, false).unwrap().cliques(); @@ -825,7 +829,7 @@ mod tests { #[test] fn test_symmetry_examples_b() { test_logger(); - let pbes = Pbes::from_text(include_str!("../../../../examples/pbes/b.text.pbes")).unwrap(); + let pbes = Pbes::from_text(include_str!("../../../../../examples/pbes/b.text.pbes")).unwrap(); let cliques = SymmetryAlgorithm::new(&pbes, false).unwrap().cliques(); @@ -835,7 +839,7 @@ mod tests { #[test] fn test_symmetry_examples_c() { test_logger(); - let pbes = Pbes::from_text(include_str!("../../../../examples/pbes/c.text.pbes")).unwrap(); + let pbes = Pbes::from_text(include_str!("../../../../../examples/pbes/c.text.pbes")).unwrap(); let algorithm = SymmetryAlgorithm::new(&pbes, false).unwrap(); let cliques = algorithm.cliques(); diff --git a/tools/mcrl2/crates/merc_pbes/tests/explore_pbes_test.rs b/tools/mcrl2/crates/merc_pbes/tests/explore_pbes_test.rs new file mode 100644 index 000000000..7a343f675 --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/tests/explore_pbes_test.rs @@ -0,0 +1,535 @@ +use std::path::Path; +use std::process::Command; + +use mcrl2::Pbes; +use merc_explore::CachingStrategy; +use merc_explore::ExplorationStrategy; +use merc_io::temp_dir; +use merc_io::traced_command; +use merc_utilities::Timing; +use merc_vpg::PG; +use merc_vpg::solve_zielonka; + +use merc_pbes::explore_pbes; +use merc_pbes::explore_pbes_parallel; +use merc_pbes::explore_srf_pbes; + +/// Explores `pbes` with both the SRF path and the general path, solves each +/// parity game with Zielonka, and asserts the initial-vertex winner agrees. +fn assert_general_matches_srf(pbes: &Pbes) { + // Normalise to positive normal form so the SRF converter accepts it. + let mut normalised = pbes.clone(); + normalised.normalize(); + + let game_srf = explore_srf_pbes( + &normalised, + ExplorationStrategy::Bfs, + CachingStrategy::None, + &Timing::new(), + ) + .expect("SRF exploration failed"); + let game_gen = explore_pbes( + normalised, + ExplorationStrategy::Bfs, + CachingStrategy::None, + &Timing::new(), + ) + .expect("General exploration failed"); + + let (sol_srf, _) = solve_zielonka(&game_srf, false); + let (sol_gen, _) = solve_zielonka(&game_gen, false); + + assert_eq!( + sol_srf[0][0], sol_gen[0][0], + "SRF and general explorers disagree on initial-vertex winner" + ); +} + +fn assert_general_matches_srf_from_text(text: &str) { + let pbes = Pbes::from_text(text).expect("Failed to parse PBES"); + assert_general_matches_srf(&pbes); +} + +#[test] +fn test_simple_mu_true() { + // mu X = val(true); solution: false (cannot prove true iteratively from bottom) + // Actually mu X = val(true) fixpoint: X = true, so solution = true + assert_general_matches_srf_from_text("pbes mu X = val(true); init X;"); +} + +#[test] +fn test_simple_mu_false() { + assert_general_matches_srf_from_text("pbes mu X = val(false); init X;"); +} + +#[test] +fn test_simple_nu_true() { + assert_general_matches_srf_from_text("pbes nu X = val(true); init X;"); +} + +#[test] +fn test_simple_nu_false() { + assert_general_matches_srf_from_text("pbes nu X = val(false); init X;"); +} + +#[test] +fn test_mu_self_loop() { + // mu X = X; init X — least fixpoint of X = X is false + assert_general_matches_srf_from_text("pbes mu X = X; init X;"); +} + +#[test] +fn test_nu_self_loop() { + // nu X = X; init X — greatest fixpoint of X = X is true + assert_general_matches_srf_from_text("pbes nu X = X; init X;"); +} + +#[test] +fn test_and_formula() { + // nu X = X && X — equivalent to nu X = X, solution true + assert_general_matches_srf_from_text("pbes nu X = X && X; init X;"); +} + +#[test] +fn test_or_formula() { + // mu X = X || X — equivalent to mu X = X, solution false + assert_general_matches_srf_from_text("pbes mu X = X || X; init X;"); +} + +#[test] +fn test_two_equations_alternation() { + // nu X = Y; mu Y = X — alternation depth 1 + assert_general_matches_srf_from_text("pbes nu X = Y; mu Y = X; init X;"); +} + +#[test] +fn test_and_of_two_pvis() { + // nu X = Y && Z; nu Y = val(true); nu Z = val(true); + assert_general_matches_srf_from_text("pbes nu X = Y && Z; nu Y = val(true); nu Z = val(true); init X;"); +} + +#[test] +fn test_or_of_two_pvis() { + assert_general_matches_srf_from_text("pbes mu X = Y || Z; mu Y = val(false); mu Z = val(false); init X;"); +} + +#[test] +fn test_nested_and_or() { + // Nested formula: nu X = (Y && Z) || W + assert_general_matches_srf_from_text( + "pbes nu X = (Y && Z) || W; nu Y = val(true); nu Z = val(true); nu W = val(false); init X;", + ); +} + +#[test] +fn test_data_param_bool() { + // PBES with a Bool parameter + assert_general_matches_srf_from_text("pbes nu X(b: Bool) = val(b); init X(true);"); +} + +#[test] +fn test_data_param_nat() { + assert_general_matches_srf_from_text("pbes mu X(n: Nat) = val(n == 0); init X(1);"); +} + +#[test] +fn test_data_param_with_pvi() { + assert_general_matches_srf_from_text("pbes nu X(n: Int) = val(n > 0) || X(n - 1); init X(3);"); +} + +#[test] +fn test_multiple_equations_data() { + assert_general_matches_srf_from_text( + "pbes mu X(n: Int) = val(n == 0) || Y(n); \ + nu Y(n: Int) = val(n > 0) && X(n - 1); \ + init X(2);", + ); +} + +/// Explores `pbes` with local caching and asserts the result matches the uncached exploration. +fn assert_cached_matches_uncached(pbes: &Pbes) { + let mut normalised = pbes.clone(); + normalised.normalize(); + + let game_uncached = explore_pbes( + normalised.clone(), + ExplorationStrategy::Bfs, + CachingStrategy::None, + &Timing::new(), + ) + .expect("uncached exploration failed"); + let game_cached = explore_pbes( + normalised, + ExplorationStrategy::Bfs, + CachingStrategy::Local, + &Timing::new(), + ) + .expect("cached exploration failed"); + + // The games must be *identical* in size, not merely have the same winner. + // A summand that mis-declares its state effect mints spurious vertices + // whose winner often still agrees, which hides the defect. + assert_eq!( + game_uncached.num_of_vertices(), + game_cached.num_of_vertices(), + "caching changed the number of vertices" + ); + assert_eq!( + game_uncached.num_of_edges(), + game_cached.num_of_edges(), + "caching changed the number of edges" + ); + + let (sol_uncached, _) = solve_zielonka(&game_uncached, false); + let (sol_cached, _) = solve_zielonka(&game_cached, false); + + // Both runs explore in the same order from the same initial state, so + // vertex indices line up and the winning sets must agree everywhere, not + // just at the initial vertex. + assert_eq!( + sol_uncached, sol_cached, + "cached and uncached explorers disagree on the winner of some vertex" + ); +} + +fn assert_cached_matches_uncached_from_text(text: &str) { + let pbes = Pbes::from_text(text).expect("Failed to parse PBES"); + assert_cached_matches_uncached(&pbes); +} + +#[test] +fn test_cached_and_formula() { + let pbes = + Pbes::from_text("pbes nu X = Y && Z; nu Y = val(true); nu Z = val(true); init X;").expect("parse failed"); + assert_cached_matches_uncached(&pbes); +} + +#[test] +fn test_cached_or_formula() { + let pbes = + Pbes::from_text("pbes mu X = Y || Z; mu Y = val(false); mu Z = val(false); init X;").expect("parse failed"); + assert_cached_matches_uncached(&pbes); +} + +/// An equation whose right-hand side rewrites to `val(true)` emits a sink, +/// which is shorter than its source state. Replaying that from the cache by +/// scattering write positions onto the source produces a padded, bogus sink. +#[test] +#[cfg_attr(miri, ignore)] +fn test_cached_equation_collapsing_to_a_sink() { + assert_cached_matches_uncached_from_text( + "pbes +nu X(n: Nat) = val(n == 3) || (Y(n) && X(n + 1)); +nu Y(n: Nat) = val(true); +init X(0);", + ); +} + +/// A quantifier carries no syntactic `&&`/`||`, but the rewriter expands it +/// into one, so the equation can emit a subformula vertex whose length +/// differs from the source state. +#[test] +#[cfg_attr(miri, ignore)] +fn test_cached_quantifier_expands_to_subformula() { + assert_cached_matches_uncached_from_text( + "pbes +nu X(n: Nat) = forall m: Nat . val(m > 2) || Y(n); +nu Y(n: Nat) = val(n == 0) || X(n + 1); +init X(0);", + ); +} + +/// An equation that passes every parameter through unchanged still depends on +/// those parameters: under an opaque effect the whole next state is cached, so +/// a passed-through value has to be part of the cache key. +#[test] +#[cfg_attr(miri, ignore)] +fn test_cached_identity_arguments_are_part_of_the_key() { + assert_cached_matches_uncached_from_text( + "pbes +nu X(m: Nat, n: Nat) = Y(m, n) || val(m == n); +nu Y(m: Nat, n: Nat) = val(m == 2) || X(m + 1, n); +init X(0, 1);", + ); +} + +/// Explores `pbes` with the parallel explorer under every caching strategy +/// and asserts each result matches the sequential uncached exploration. +/// +/// The parallel explorer numbers vertices sparsely (see `explore_parallel`), +/// so its game has extra unreachable deadlock vertices; vertex 0 is the +/// initial state in both, so the comparison is on the solution rather than +/// on the vertex and edge counts. +fn assert_parallel_matches_sequential(pbes: &Pbes) { + let mut normalised = pbes.clone(); + normalised.normalize(); + + let sequential = explore_pbes( + normalised.clone(), + ExplorationStrategy::Bfs, + CachingStrategy::None, + &Timing::new(), + ) + .expect("sequential exploration failed"); + let (sol_sequential, _) = solve_zielonka(&sequential, false); + + for caching in [CachingStrategy::None, CachingStrategy::Local] { + let parallel = explore_pbes_parallel(normalised.clone(), 4, caching, false, &Timing::new()) + .expect("parallel exploration failed"); + let (sol_parallel, _) = solve_zielonka(¶llel, false); + + assert_eq!( + sol_sequential[0][0], sol_parallel[0][0], + "parallel explorer with {caching:?} caching disagrees with the sequential one" + ); + } +} + +#[test] +fn test_parallel_and_formula() { + let pbes = + Pbes::from_text("pbes nu X = Y && Z; nu Y = val(true); nu Z = val(true); init X;").expect("parse failed"); + assert_parallel_matches_sequential(&pbes); +} + +#[test] +fn test_parallel_or_formula() { + let pbes = + Pbes::from_text("pbes mu X = Y || Z; mu Y = val(false); mu Z = val(false); init X;").expect("parse failed"); + assert_parallel_matches_sequential(&pbes); +} + +#[test] +fn test_parallel_data_param_with_pvi() { + let pbes = Pbes::from_text("pbes nu X(n: Int) = val(n > 0) || X(n - 1); init X(3);").expect("parse failed"); + assert_parallel_matches_sequential(&pbes); +} + +/// The initial state is rewritten before it is explored from, as mCRL2's +/// pbesinst does. +/// +/// Every other state is reached through the rewriter, so an initial argument +/// left as written is a value no successor ever equals: `X(1 + 1)` would get +/// a vertex of its own that no edge leads back to, and rewriting its body +/// leaves an unevaluated `1 + 1` behind. +#[test] +fn test_initial_state_is_rewritten() { + let pbes = Pbes::from_text("pbes nu X(n: Nat) = val(n >= 3) || X(n + 1);\ninit X(1 + 1);").expect("parse failed"); + + let game = explore_pbes(pbes, ExplorationStrategy::Bfs, CachingStrategy::None, &Timing::new()) + .expect("an initial state that needs rewriting must still be explorable"); + + // X(2), X(3) and the true sink: X(1 + 1) is X(2), not a state of its own. + assert_eq!(game.num_of_vertices(), 3); +} + +/// Preprocessing instantiates the global variables, which is what makes a +/// PBES with a global variable in its initial state explorable at all. +#[test] +fn test_preprocess_instantiates_global_variables() { + let text = "glob g: Nat;\npbes nu X(n: Nat) = val(n >= 3) || X(n + 1);\ninit X(g);"; + + let unprocessed = Pbes::from_text(text).expect("parse failed"); + assert!( + explore_pbes( + unprocessed, + ExplorationStrategy::Bfs, + CachingStrategy::None, + &Timing::new() + ) + .is_err(), + "an uninstantiated global variable cannot be rewritten to a value" + ); + + let mut preprocessed = Pbes::from_text(text).expect("parse failed"); + preprocessed.preprocess(&Timing::new()).expect("preprocessing failed"); + explore_pbes( + preprocessed, + ExplorationStrategy::Bfs, + CachingStrategy::None, + &Timing::new(), + ) + .expect("after preprocessing the global variable has a value"); +} + +/// A nested formula reachable from two equations of *different* priority is +/// a single vertex, and merging it does not change who wins. +/// +/// `X` (priority 2) and `W` (priority 1) both have `Y(n) || Z(n)` as their +/// first conjunct, so both reach the very same disjunction term. Keying the +/// subformula vertex on the enclosing priority as well as on the term would +/// split it into two vertices here, which is what mCRL2's `SG1` does not do +/// either: `insert_vertex(psi)` is keyed on the formula alone. +#[test] +fn test_shared_subformula_is_one_vertex() { + let pbes = Pbes::from_text( + "pbes nu X(n: Nat) = (Y(n) || Z(n)) && W(n); + mu W(n: Nat) = (Y(n) || Z(n)) && X(n); + nu Y(n: Nat) = val(n > 0); + nu Z(n: Nat) = val(n < 1); + init X(0);", + ) + .expect("parse failed"); + + // The SRF explorer has no subformula vertices at all, so it is an + // independent check that the merged game still has the same winner. + assert_general_matches_srf(&pbes); + + let mut normalised = pbes.clone(); + normalised.normalize(); + let game = explore_pbes( + normalised, + ExplorationStrategy::Bfs, + CachingStrategy::None, + &Timing::new(), + ) + .expect("general exploration failed"); + + // 4 instantiations + 1 shared disjunction + 2 sinks. It would be 8 if + // the disjunction were split per priority. + assert_eq!( + game.num_of_vertices(), + 7, + "the disjunction shared by X and W must be a single vertex" + ); +} + +#[test] +fn test_parallel_random_pbes_seeds() { + use merc_syntax::random_pbes; + use rand::SeedableRng; + + for seed in 0u64..50 { + let mut rng = rand::rngs::SmallRng::seed_from_u64(seed); + let pbes_ast = random_pbes(&mut rng, 3, 2, 3, false, false); + let pbes = Pbes::from_text(&pbes_ast.to_string()).expect("parse failed"); + assert_parallel_matches_sequential(&pbes); + } +} + +#[test] +fn test_cached_random_pbes_seeds() { + use merc_syntax::random_pbes; + use rand::SeedableRng; + + for seed in 0u64..50 { + let mut rng = rand::rngs::SmallRng::seed_from_u64(seed); + let pbes_ast = random_pbes(&mut rng, 3, 2, 3, false, false); + let pbes = Pbes::from_text(&pbes_ast.to_string()).expect("parse failed"); + assert_cached_matches_uncached(&pbes); + } +} + +#[test] +fn test_random_pbes_seeds() { + use merc_syntax::random_pbes; + use rand::SeedableRng; + + for seed in 0u64..50 { + let mut rng = rand::rngs::SmallRng::seed_from_u64(seed); + // propositional only (no quantifiers, no integers) — stable state spaces + let pbes_ast = random_pbes(&mut rng, 3, 2, 3, false, false); + let text = pbes_ast.to_string(); + assert_general_matches_srf_from_text(&text); + } +} + +fn convert_text_pbes_and_compare(text_pbes_path: &str) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let txt2pbes = Path::new(&mcrl2_path).join("txt2pbes"); + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(text_pbes_path); + assert!(path.exists(), "file not found: {}", path.display()); + + let temp = temp_dir("test_explore_pbes").unwrap(); + let pbes_path = temp.path().join("spec.pbes"); + + let status = traced_command(Command::new(&txt2pbes).arg(&path).arg(&pbes_path)).expect("txt2pbes failed"); + assert!(status.success()); + + let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); + assert_general_matches_srf(&pbes); +} + +fn convert_mcrl2_and_compare(spec: &str, formula: &str) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); + let lps2pbes = Path::new(&mcrl2_path).join("lps2pbes"); + + let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec); + let formula_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(formula); + assert!(spec_path.exists(), "spec not found: {}", spec_path.display()); + assert!(formula_path.exists(), "formula not found: {}", formula_path.display()); + + let temp = temp_dir("test_explore_pbes").unwrap(); + let lps_path = temp.path().join("spec.lps"); + let pbes_path = temp.path().join("spec.pbes"); + + let status = traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)).expect("mcrl22lps failed"); + assert!(status.success()); + + let status = traced_command( + Command::new(&lps2pbes) + .arg("-f") + .arg(&formula_path) + .arg(&lps_path) + .arg(&pbes_path), + ) + .expect("lps2pbes failed"); + assert!(status.success()); + + let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); + assert_general_matches_srf(&pbes); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_file_a_text_pbes() { + convert_text_pbes_and_compare("../../../../examples/pbes/a.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_file_b_text_pbes() { + convert_text_pbes_and_compare("../../../../examples/pbes/b.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_file_c_text_pbes() { + convert_text_pbes_and_compare("../../../../examples/pbes/c.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_file_par_nodeadlock() { + convert_mcrl2_and_compare( + "../../../../examples/mCRL2/academic/par/par.mcrl2", + "../../../../examples/mCRL2/academic/par/nodeadlock.mcf", + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_file_abp() { + convert_mcrl2_and_compare( + "../../../../examples/mCRL2/academic/abp/abp.mcrl2", + "../../../../examples/mCRL2/academic/abp/infinitely_often_enabled_then_infinitely_often_taken.mcf", + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_file_dining3_nodeadlock() { + convert_mcrl2_and_compare( + "../../../../examples/mCRL2/academic/dining/dining3.mcrl2", + "../../../../examples/mCRL2/academic/dining/nodeadlock.mcf", + ); +} diff --git a/tools/mcrl2/crates/merc_pbes/tests/explore_srf_test.rs b/tools/mcrl2/crates/merc_pbes/tests/explore_srf_test.rs new file mode 100644 index 000000000..76541363f --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/tests/explore_srf_test.rs @@ -0,0 +1,317 @@ +use std::path::Path; +use std::process::Command; + +use mcrl2::Pbes; +use merc_explore::CachingStrategy; +use merc_explore::ExplorationStrategy; +use merc_io::temp_dir; +use merc_io::traced_command; +use merc_utilities::Timing; +use merc_vpg::Set; +use merc_vpg::solve_zielonka; + +use merc_pbes::explore_srf_pbes; +use merc_pbes::explore_srf_pbes_parallel; + +fn pbessolve_result(pbessolve: &Path, pbes_path: &Path) -> bool { + let output = Command::new(pbessolve) + .arg(pbes_path) + .output() + .expect("Failed to execute pbessolve"); + assert!( + output.status.success(), + "pbessolve failed with status: {}", + output.status + ); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + if combined.to_lowercase().contains("true") { + true + } else if combined.to_lowercase().contains("false") { + false + } else { + panic!( + "pbessolve produced unexpected output for {}: stdout={stdout:?} stderr={stderr:?}", + pbes_path.display() + ) + } +} + +/// Converts a text PBES with `txt2pbes`, solves it with our parity game solver, +/// and asserts the result matches `pbessolve`. +fn compare_text_pbes_with_pbessolve(text_pbes_relative_path: &str) { + compare_text_pbes_with_pbessolve_caching(text_pbes_relative_path, CachingStrategy::None); +} + +/// Like [`compare_text_pbes_with_pbessolve`] but explores the PBES with the +/// given [`CachingStrategy`]. +fn compare_text_pbes_with_pbessolve_caching(text_pbes_relative_path: &str, caching: CachingStrategy) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let txt2pbes = Path::new(&mcrl2_path).join("txt2pbes"); + let pbessolve = Path::new(&mcrl2_path).join("pbessolve"); + + let text_pbes_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(text_pbes_relative_path); + assert!( + text_pbes_path.exists(), + "Text PBES file not found: {}", + text_pbes_path.display() + ); + + let temp = temp_dir("test_explore_srf").unwrap(); + let pbes_path = temp.path().join("spec.pbes"); + + let status = traced_command(Command::new(&txt2pbes).arg(&text_pbes_path).arg(&pbes_path)) + .expect("Failed to execute txt2pbes"); + assert!(status.success(), "txt2pbes failed with status: {status}"); + + let reference = pbessolve_result(&pbessolve, &pbes_path); + + let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); + let game = explore_srf_pbes(&pbes, ExplorationStrategy::Bfs, caching, &Timing::new()) + .expect("Failed to build parity game"); + let (solution, _) = solve_zielonka(&game, false); + let result = solution[0][0]; + + assert_eq!( + result, reference, + "PBES solution mismatch for {text_pbes_relative_path}: our solver says {result}, pbessolve says {reference}" + ); +} + +/// Generates a PBES from an mCRL2 spec and a modal formula using `mcrl22lps` +/// and `lps2pbes`, then compares our solver's result with `pbessolve`. +fn compare_mcrl2_spec_with_pbessolve(spec_relative_path: &str, formula_relative_path: &str) { + compare_mcrl2_spec_with_pbessolve_caching(spec_relative_path, formula_relative_path, CachingStrategy::None); +} + +/// Like [`compare_mcrl2_spec_with_pbessolve`] but explores the PBES with the +/// given [`CachingStrategy`]. +fn compare_mcrl2_spec_with_pbessolve_caching( + spec_relative_path: &str, + formula_relative_path: &str, + caching: CachingStrategy, +) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); + let lps2pbes = Path::new(&mcrl2_path).join("lps2pbes"); + let pbessolve = Path::new(&mcrl2_path).join("pbessolve"); + + let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); + let formula_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(formula_relative_path); + assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); + assert!( + formula_path.exists(), + "Formula file not found: {}", + formula_path.display() + ); + + let temp = temp_dir("test_explore_srf").unwrap(); + let lps_path = temp.path().join("spec.lps"); + let pbes_path = temp.path().join("spec.pbes"); + + let status = + traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)).expect("Failed to execute mcrl22lps"); + assert!(status.success(), "mcrl22lps failed with status: {status}"); + + let status = traced_command( + Command::new(&lps2pbes) + .arg("-f") + .arg(&formula_path) + .arg(&lps_path) + .arg(&pbes_path), + ) + .expect("Failed to execute lps2pbes"); + assert!(status.success(), "lps2pbes failed with status: {status}"); + + let reference = pbessolve_result(&pbessolve, &pbes_path); + + let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); + let game = explore_srf_pbes(&pbes, ExplorationStrategy::Bfs, caching, &Timing::new()) + .expect("Failed to build parity game"); + let (solution, _) = solve_zielonka(&game, false); + let result = solution[0][0]; + + assert_eq!( + result, reference, + "PBES solution mismatch for {spec_relative_path} with {formula_relative_path}: \ + our solver says {result}, pbessolve says {reference}" + ); +} + +/// Builds the parity game from `pbes_path` both sequentially and with the +/// parallel BFS on several threads, and asserts they solve to the same winner +/// for the initial vertex (vertex 0 is the initial state in both, even though +/// the remaining numbering differs). +fn assert_parallel_matches_sequential_pbes(pbes_path: &Path) { + let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); + + let sequential = explore_srf_pbes(&pbes, ExplorationStrategy::Bfs, CachingStrategy::None, &Timing::new()) + .expect("Sequential exploration failed"); + let (sequential_solution, _) = solve_zielonka(&sequential, false); + + // The parallel explorer must agree with the sequential one regardless of + // the caching strategy in effect, so check both. + for caching in [CachingStrategy::None, CachingStrategy::Local] { + assert_parallel_caching_matches_sequential(pbes_path, &pbes, &sequential_solution, caching); + } +} + +/// Helper for [`assert_parallel_matches_sequential_pbes`]: builds the parity +/// game with the parallel BFS under the given `caching` strategy and asserts +/// it solves to the same winner for the initial vertex. +fn assert_parallel_caching_matches_sequential( + pbes_path: &Path, + pbes: &Pbes, + sequential_solution: &[Set; 2], + caching: CachingStrategy, +) { + let parallel = + explore_srf_pbes_parallel(pbes, 4, caching, false, &Timing::new()).expect("Parallel exploration failed"); + + // The parallel explorer numbers vertices sparsely (see `explore_parallel`), + // so its game has extra unreachable deadlock vertices and thus more + // vertices and edges. They cannot change the winner of the initial vertex, + // so we compare on the solution rather than on exact counts. + let (parallel_solution, _) = solve_zielonka(¶llel, false); + assert_eq!( + parallel_solution[0][0], + sequential_solution[0][0], + "Parallel and sequential solutions differ for {}", + pbes_path.display() + ); +} + +/// Converts a text PBES with `txt2pbes` and asserts parallel and sequential +/// parity-game construction agree. +fn compare_parallel_text_pbes(text_pbes_relative_path: &str) { + let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { + println!("Skipping test: MCRL2_PATH not set"); + return; + }; + + let txt2pbes = Path::new(&mcrl2_path).join("txt2pbes"); + let text_pbes_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(text_pbes_relative_path); + assert!( + text_pbes_path.exists(), + "Text PBES file not found: {}", + text_pbes_path.display() + ); + + let temp = temp_dir("test_explore_srf_parallel").unwrap(); + let pbes_path = temp.path().join("spec.pbes"); + + let status = traced_command(Command::new(&txt2pbes).arg(&text_pbes_path).arg(&pbes_path)) + .expect("Failed to execute txt2pbes"); + assert!(status.success(), "txt2pbes failed with status: {status}"); + + assert_parallel_matches_sequential_pbes(&pbes_path); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_parallel_a_text_pbes() { + compare_parallel_text_pbes("../../../../examples/pbes/a.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_parallel_b_text_pbes() { + compare_parallel_text_pbes("../../../../examples/pbes/b.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_parallel_c_text_pbes() { + compare_parallel_text_pbes("../../../../examples/pbes/c.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_a_text_pbes() { + compare_text_pbes_with_pbessolve("../../../../examples/pbes/a.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_b_text_pbes() { + compare_text_pbes_with_pbessolve("../../../../examples/pbes/b.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_c_text_pbes() { + compare_text_pbes_with_pbessolve("../../../../examples/pbes/c.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_par_nodeadlock() { + compare_mcrl2_spec_with_pbessolve( + "../../../../examples/mCRL2/academic/par/par.mcrl2", + "../../../../examples/mCRL2/academic/par/nodeadlock.mcf", + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_allow_nodeadlock() { + compare_mcrl2_spec_with_pbessolve( + "../../../../examples/mCRL2/academic/allow/allow.mcrl2", + "../../../../examples/mCRL2/academic/allow/nodeadlock.mcf", + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_block_nodeadlock() { + compare_mcrl2_spec_with_pbessolve( + "../../../../examples/mCRL2/academic/block/block.mcrl2", + "../../../../examples/mCRL2/academic/block/nodeadlock.mcf", + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_abp() { + compare_mcrl2_spec_with_pbessolve( + "../../../../examples/mCRL2/academic/abp/abp.mcrl2", + "../../../../examples/mCRL2/academic/abp/infinitely_often_enabled_then_infinitely_often_taken.mcf", + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_cabp() { + compare_mcrl2_spec_with_pbessolve( + "../../../../examples/mCRL2/academic/cabp/cabp.mcrl2", + "../../../../examples/mCRL2/academic/cabp/infinitely_often_enabled_then_infinitely_often_taken.mcf", + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_abp_local_cache() { + compare_mcrl2_spec_with_pbessolve_caching( + "../../../../examples/mCRL2/academic/abp/abp.mcrl2", + "../../../../examples/mCRL2/academic/abp/infinitely_often_enabled_then_infinitely_often_taken.mcf", + CachingStrategy::Local, + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_mcrl2_dining3_nodeadlock() { + compare_mcrl2_spec_with_pbessolve( + "../../../../examples/mCRL2/academic/dining/dining3.mcrl2", + "../../../../examples/mCRL2/academic/dining/nodeadlock.mcf", + ); +} diff --git a/tools/mcrl2/crates/merc_pbes/tests/explore_symbolic_srf_test.rs b/tools/mcrl2/crates/merc_pbes/tests/explore_symbolic_srf_test.rs new file mode 100644 index 000000000..ca34e466f --- /dev/null +++ b/tools/mcrl2/crates/merc_pbes/tests/explore_symbolic_srf_test.rs @@ -0,0 +1,57 @@ +use std::path::Path; + +use mcrl2::Pbes; +use merc_explore::CachingStrategy; +use merc_explore::ExplorationStrategy; +use merc_utilities::Timing; +use merc_vpg::PG; + +use merc_pbes::explore_pbes_symbolic; +use merc_pbes::explore_srf_pbes; + +/// Reads a textual PBES, explores it both explicitly (into a parity game) +/// and symbolically (into an LDD), and asserts the number of reachable BES +/// equations agrees. The explicit parity game has exactly one vertex per +/// reachable equation, so its vertex count must equal the symbolic state +/// count. +fn assert_symbolic_matches_explicit(text_pbes_relative_path: &str) { + let text_pbes_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(text_pbes_relative_path); + assert!( + text_pbes_path.exists(), + "Text PBES file not found: {}", + text_pbes_path.display() + ); + + let pbes = Pbes::from_text_file(text_pbes_path.to_str().unwrap()).expect("Failed to read text PBES"); + + let game = explore_srf_pbes(&pbes, ExplorationStrategy::Bfs, CachingStrategy::None, &Timing::new()) + .expect("Failed to build parity game"); + + let storage = oxidd::ldd::new_manager(1 << 20, 1 << 20, 1); + let timing = Timing::new(); + let states = explore_pbes_symbolic(&storage, &pbes, &timing).expect("Failed to explore PBES symbolically"); + + assert_eq!( + states.len() as usize, + game.num_of_vertices(), + "Symbolic state count and explicit vertex count differ for {text_pbes_relative_path}" + ); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_symbolic_a_text_pbes() { + assert_symbolic_matches_explicit("../../../../examples/pbes/a.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_symbolic_b_text_pbes() { + assert_symbolic_matches_explicit("../../../../examples/pbes/b.text.pbes"); +} + +#[test] +#[cfg_attr(miri, ignore)] +fn test_symbolic_c_text_pbes() { + assert_symbolic_matches_explicit("../../../../examples/pbes/c.text.pbes"); +} diff --git a/tools/mcrl2/lps/Cargo.toml b/tools/mcrl2/lps/Cargo.toml index 0c634d278..7ea660112 100644 --- a/tools/mcrl2/lps/Cargo.toml +++ b/tools/mcrl2/lps/Cargo.toml @@ -5,16 +5,20 @@ edition.workspace = true license.workspace = true rust-version.workspace = true +[[bin]] +name = "merc-lps" +path = "src/main.rs" +# Avoid a rustdoc output filename collision with the `merc_lps` library crate. +doc = false + [features] # Tracks per-summand cache and control-flow pruning counters during exploration. -metrics = ["merc_explore/metrics"] +metrics = ["merc_lps/metrics"] [dependencies] mcrl2.workspace = true -merc_collections.workspace = true -merc_data.workspace = true merc_explore = { workspace = true, features = ["clap"] } -merc_io.workspace = true +merc_lps.workspace = true merc_lts = { workspace = true, features = ["clap"] } merc_symbolic = { workspace = true, features = ["clap"] } merc_tools.workspace = true @@ -24,17 +28,3 @@ oxidd.workspace = true clap.workspace = true env_logger.workspace = true -itertools.workspace = true -log.workspace = true -rand.workspace = true -rayon.workspace = true -serde_json.workspace = true -serde.workspace = true -streaming-iterator.workspace = true -thiserror.workspace = true - -[dev-dependencies] -merc_reduction.workspace = true -merc_syntax.workspace = true -rustc-hash.workspace = true -tempfile.workspace = true \ No newline at end of file diff --git a/tools/mcrl2/lps/src/cfg_lps_test.rs b/tools/mcrl2/lps/src/cfg_lps_test.rs deleted file mode 100644 index 8314ad5ce..000000000 --- a/tools/mcrl2/lps/src/cfg_lps_test.rs +++ /dev/null @@ -1,167 +0,0 @@ -#[cfg(test)] -mod tests { - use std::fs::File; - use std::path::Path; - use std::process::Command; - - use mcrl2::read_lps; - use merc_explore::CachingStrategy; - use merc_explore::ExplorationStrategy; - use merc_io::temp_dir; - use merc_io::traced_command; - use merc_lts::LTS; - use merc_lts::LtsBuilderFast; - use merc_lts::StateIndex; - use merc_reduction::Equivalence; - use merc_reduction::compare_lts; - use merc_syntax::random_lps; - use merc_utilities::Timing; - use merc_utilities::random_test; - - use crate::explore_explicit::Mcrl2MultiActionLabel; - use crate::explore_lps_explicit; - - /// Explores `lps_path` both with the plain explicit explorer and with the - /// control-flow-pruning explorer, and asserts the two LTSs have equal - /// state/transition counts and are strongly bisimilar. Pruning summands - /// whose guard cannot hold must never change the explored transition system. - fn assert_cfg_matches_explicit(lps_path: &Path) { - let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); - - let mut reference_builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); - explore_lps_explicit( - &mut reference_builder, - &lps, - CachingStrategy::None, - ExplorationStrategy::Dfs, - false, - &Timing::new(), - ) - .expect("Explicit exploration failed"); - let reference = reference_builder - .finish(StateIndex::new(0), false) - .relabel(|label| Ok(label.to_string())) - .unwrap(); - - let mut cfg_builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); - explore_lps_explicit( - &mut cfg_builder, - &lps, - CachingStrategy::None, - ExplorationStrategy::Dfs, - true, - &Timing::new(), - ) - .expect("Control flow exploration failed"); - let cfg = cfg_builder - .finish(StateIndex::new(0), false) - .relabel(|label| Ok(label.to_string())) - .unwrap(); - - assert_eq!( - reference.num_of_states(), - cfg.num_of_states(), - "State count mismatch for {}", - lps_path.display() - ); - assert_eq!( - reference.num_of_transitions(), - cfg.num_of_transitions(), - "Transition count mismatch for {}", - lps_path.display() - ); - assert!( - compare_lts(Equivalence::StrongBisim, reference, cfg, false, false, &Timing::new()).0, - "Control flow and explicit LTSs are not strongly bisimilar for {}", - lps_path.display() - ); - } - - /// Runs `mcrl22lps` on a `.mcrl2` specification and asserts that control-flow - /// and plain explicit exploration agree. - fn compare_cfg_with_explicit(spec_relative_path: &str) { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); - let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); - assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); - - let temp_dir = temp_dir("test_cfg_lps").unwrap(); - let lps_path = temp_dir.path().join("spec.lps"); - - let status = traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)) - .expect("Failed to execute mcrl22lps"); - assert!(status.success(), "mcrl22lps failed with status: {status}"); - - assert_cfg_matches_explicit(&lps_path); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_cfg_abp() { - compare_cfg_with_explicit("../../../examples/mCRL2/academic/abp/abp.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_cfg_cabp() { - compare_cfg_with_explicit("../../../examples/mCRL2/academic/cabp/cabp.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_cfg_allow() { - compare_cfg_with_explicit("../../../examples/mCRL2/academic/allow/allow.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_cfg_block() { - compare_cfg_with_explicit("../../../examples/mCRL2/academic/block/block.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_cfg_dining3() { - compare_cfg_with_explicit("../../../examples/mCRL2/academic/dining/dining3.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_cfg_par() { - compare_cfg_with_explicit("../../../examples/mCRL2/academic/par/par.mcrl2"); - } - - /// Generates random LPS specs with [`random_lps`] and asserts that - /// control-flow and plain explicit exploration agree on each. - #[test] - #[cfg_attr(miri, ignore)] - fn test_cfg_random_lps() { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let txt2lps = Path::new(&mcrl2_path).join("txt2lps"); - - let temp_dir = temp_dir("test_cfg_random_lps").unwrap(); - let spec_path = temp_dir.path().join("spec.mcrl2"); - let lps_path = temp_dir.path().join("spec.lps"); - - random_test(20, |rng| { - let spec = random_lps(rng, 5, 3, 0.4); - std::fs::write(&spec_path, spec.to_string()).expect("Failed to write random LPS spec"); - - let status = traced_command(Command::new(&txt2lps).arg(&spec_path).arg(&lps_path)) - .expect("Failed to execute txt2lps"); - assert!(status.success(), "txt2lps failed with status: {status}"); - - // Ensure the generated LPS can be read back before exploring it. - let _ = File::open(&lps_path).expect("Failed to open generated LPS"); - assert_cfg_matches_explicit(&lps_path); - }); - } -} diff --git a/tools/mcrl2/lps/src/explore_lps_test.rs b/tools/mcrl2/lps/src/explore_lps_test.rs deleted file mode 100644 index 6c653a627..000000000 --- a/tools/mcrl2/lps/src/explore_lps_test.rs +++ /dev/null @@ -1,393 +0,0 @@ -#[cfg(test)] -mod tests { - use std::fs::File; - use std::io::Cursor; - use std::path::Path; - use std::process::Command; - - use mcrl2::read_lps; - use merc_explore::CachingStrategy; - use merc_explore::ExplorationStrategy; - use merc_io::temp_dir; - use merc_io::traced_command; - use merc_lts::AutStream; - use merc_lts::LTS; - use merc_lts::LtsBuilderFast; - use merc_lts::LtsMultiAction; - use merc_lts::MutexLtsBuilder; - use merc_lts::SimpleAction; - use merc_lts::StateIndex; - use merc_lts::read_mcrl2_aut; - use merc_lts::write_mcrl2_aut; - use merc_reduction::Equivalence; - use merc_reduction::compare_lts; - use merc_syntax::random_lps; - use merc_utilities::Timing; - use merc_utilities::random_test; - - use crate::explore_explicit::Mcrl2MultiActionLabel; - use crate::explore_explicit::explore_lps_explicit_parallel; - use crate::explore_lps_explicit; - - /// Runs `mcrl22lps` and `lps2lts` on a `.mcrl2` specification, explores the - /// LPS with `explore_lps_explicit`, and asserts strong bisimilarity between - /// the two resulting LTSs. - fn compare_with_lps2lts(spec_relative_path: &str) { - compare_with_lps2lts_caching(spec_relative_path, CachingStrategy::None); - } - - /// Like [`compare_with_lps2lts`] but explores the LPS with the given - /// [`CachingStrategy`]. - fn compare_with_lps2lts_caching(spec_relative_path: &str, strategy: CachingStrategy) { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); - let lps2lts = Path::new(&mcrl2_path).join("lps2lts"); - - let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); - assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); - - let temp_dir = temp_dir("test_explore_lps").unwrap(); - let lps_path = temp_dir.path().join("spec.lps"); - let aut_path = temp_dir.path().join("reference.aut"); - - let status = traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)) - .expect("Failed to execute mcrl22lps"); - assert!(status.success(), "mcrl22lps failed with status: {status}"); - - let status = - traced_command(Command::new(&lps2lts).arg(&lps_path).arg(&aut_path)).expect("Failed to execute lps2lts"); - assert!(status.success(), "lps2lts failed with status: {status}"); - - // Parse the labels as multi-actions (a multiset of actions) rather than - // comparing the pretty-printed strings verbatim: mCRL2 does not - // guarantee a canonical order for the `|`-separated actions of a - // multi-action, so two equivalent multi-actions (e.g. reached via - // different summands, or printed by a different tool) can be printed - // with their actions in a different order. - let reference_lts = read_mcrl2_aut(File::open(&aut_path).unwrap()) - .expect("Failed to read reference .aut") - .relabel(|label| LtsMultiAction::::from_string(&label)) - .unwrap(); - - let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); - let mut builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); - explore_lps_explicit( - &mut builder, - &lps, - strategy, - ExplorationStrategy::Dfs, - false, - &Timing::new(), - ) - .expect("Failed to explore LPS"); - let result_lts = builder.finish(StateIndex::new(0), false); - - write_mcrl2_aut( - &mut File::create(temp_dir.path().join("result.aut")).unwrap(), - &result_lts, - ) - .expect("Failed to write result .aut"); - - assert_eq!( - reference_lts.num_of_states(), - result_lts.num_of_states(), - "State count mismatch for {spec_relative_path} with {strategy:?}" - ); - assert_eq!( - reference_lts.num_of_transitions(), - result_lts.num_of_transitions(), - "Transition count mismatch for {spec_relative_path} with {strategy:?}" - ); - assert!( - compare_lts( - Equivalence::StrongBisim, - reference_lts, - result_lts - .relabel(|label| LtsMultiAction::::from_string(&label.to_string())) - .unwrap(), - false, - false, - &Timing::new(), - ) - .0, - "LTSs are not strongly bisimilar for {spec_relative_path} with {strategy:?}" - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_abp() { - compare_with_lps2lts("../../../examples/mCRL2/academic/abp/abp.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_cabp() { - compare_with_lps2lts("../../../examples/mCRL2/academic/cabp/cabp.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_allow() { - compare_with_lps2lts("../../../examples/mCRL2/academic/allow/allow.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_block() { - compare_with_lps2lts("../../../examples/mCRL2/academic/block/block.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_dining3() { - compare_with_lps2lts("../../../examples/mCRL2/academic/dining/dining3.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_par() { - compare_with_lps2lts("../../../examples/mCRL2/academic/par/par.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_abp_local_cache() { - compare_with_lps2lts_caching("../../../examples/mCRL2/academic/abp/abp.mcrl2", CachingStrategy::Local); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_cabp_local_cache() { - compare_with_lps2lts_caching( - "../../../examples/mCRL2/academic/cabp/cabp.mcrl2", - CachingStrategy::Local, - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_dining3_local_cache() { - compare_with_lps2lts_caching( - "../../../examples/mCRL2/academic/dining/dining3.mcrl2", - CachingStrategy::Local, - ); - } - - /// Generates random LPS specs using [`random_lps`], explores them with - /// [`explore_lps_explicit`], and asserts strong bisimilarity against `lps2lts`. - /// - /// Random specs are written to a temp file and converted with `txt2lps` (no - /// linearisation step required for the FSM-shaped output of [`random_lps`]). - fn compare_random_lps_with_lps2lts(strategy: CachingStrategy) { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let txt2lps = Path::new(&mcrl2_path).join("txt2lps"); - let lps2lts = Path::new(&mcrl2_path).join("lps2lts"); - - let temp_dir = temp_dir("test_explore_random_lps").unwrap(); - let spec_path = temp_dir.path().join("spec.mcrl2"); - let lps_path = temp_dir.path().join("spec.lps"); - let aut_path = temp_dir.path().join("reference.aut"); - - random_test(20, |rng| { - let spec = random_lps(rng, 5, 3, 0.4); - std::fs::write(&spec_path, spec.to_string()).expect("Failed to write random LPS spec"); - - let status = traced_command(Command::new(&txt2lps).arg(&spec_path).arg(&lps_path)) - .expect("Failed to execute txt2lps"); - assert!(status.success(), "txt2lps failed with status: {status}"); - - let status = traced_command(Command::new(&lps2lts).arg(&lps_path).arg(&aut_path)) - .expect("Failed to execute lps2lts"); - assert!(status.success(), "lps2lts failed with status: {status}"); - - let reference_lts = read_mcrl2_aut(File::open(&aut_path).unwrap()).expect("Failed to read reference .aut"); - - let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); - let mut builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); - explore_lps_explicit( - &mut builder, - &lps, - strategy, - ExplorationStrategy::Dfs, - false, - &Timing::new(), - ) - .expect("Failed to explore LPS"); - let result_lts = builder.finish(StateIndex::new(0), false); - - assert_eq!( - reference_lts.num_of_states(), - result_lts.num_of_states(), - "State count mismatch with {strategy:?}" - ); - assert_eq!( - reference_lts.num_of_transitions(), - result_lts.num_of_transitions(), - "Transition count mismatch with {strategy:?}" - ); - assert!( - compare_lts( - Equivalence::StrongBisim, - reference_lts, - result_lts.relabel(|label| { Ok(label.to_string()) }).unwrap(), - false, - false, - &Timing::new(), - ) - .0, - "LTSs are not strongly bisimilar with {strategy:?}" - ); - }); - } - - /// Explores `lps_path` both sequentially and with the parallel work-stealing - /// search on several threads, and asserts the two LTSs are strongly - /// bisimilar. The parallel explorer numbers states sparsely (see the - /// assertion below), so its LTS carries extra unreachable deadlock states and - /// the comparison is only up to bisimulation rather than on exact counts. - fn assert_parallel_matches_sequential(lps_path: &Path) { - let lps = read_lps(lps_path.to_str().unwrap()).expect("Failed to read LPS"); - - // Sequential reference, relabelled to strings for comparison. - let mut builder: LtsBuilderFast = LtsBuilderFast::new(Vec::new(), Vec::new()); - explore_lps_explicit( - &mut builder, - &lps, - CachingStrategy::None, - ExplorationStrategy::Bfs, - false, - &Timing::new(), - ) - .expect("Sequential exploration failed"); - let sequential = builder - .finish(StateIndex::new(0), false) - .relabel(|label| Ok(label.to_string())) - .unwrap(); - - // Parallel exploration across several threads, streamed into an - // in-memory AUT buffer (guarded by a `MutexLtsBuilder`) and read back as - // a string-labelled LTS. - let mut buffer = Cursor::new(Vec::new()); - { - let mut builder = MutexLtsBuilder::new(AutStream::new_mcrl2(&mut buffer).unwrap()); - explore_lps_explicit_parallel( - &mut builder, - &lps, - CachingStrategy::None, - 4, - false, - false, - &Timing::new(), - ) - .expect("Parallel exploration failed"); - } - buffer.set_position(0); - let parallel = read_mcrl2_aut(&mut buffer).expect("Failed to read parallel AUT output"); - - // The parallel explorer numbers states sparsely (see `explore_parallel`), - // so its LTS has extra unreachable deadlock states: compare up to - // bisimulation rather than on exact state counts. - assert!( - compare_lts( - Equivalence::StrongBisim, - parallel, - sequential, - false, - false, - &Timing::new() - ) - .0, - "Parallel and sequential LTSs are not strongly bisimilar for {}", - lps_path.display() - ); - } - - /// Runs `mcrl22lps` on a `.mcrl2` specification and asserts that parallel and - /// sequential explicit exploration agree. - fn compare_parallel_with_sequential(spec_relative_path: &str) { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); - let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); - assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); - - let temp_dir = temp_dir("test_explore_lps_parallel").unwrap(); - let lps_path = temp_dir.path().join("spec.lps"); - - let status = traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)) - .expect("Failed to execute mcrl22lps"); - assert!(status.success(), "mcrl22lps failed with status: {status}"); - - assert_parallel_matches_sequential(&lps_path); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_parallel_abp() { - compare_parallel_with_sequential("../../../examples/mCRL2/academic/abp/abp.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_parallel_cabp() { - compare_parallel_with_sequential("../../../examples/mCRL2/academic/cabp/cabp.mcrl2"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_parallel_dining3() { - compare_parallel_with_sequential("../../../examples/mCRL2/academic/dining/dining3.mcrl2"); - } - - /// Generates random LPS specs with [`random_lps`] and asserts that parallel - /// and sequential exploration agree on each. - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_explore_parallel_random_lps() { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let txt2lps = Path::new(&mcrl2_path).join("txt2lps"); - - let temp_dir = temp_dir("test_explore_random_lps_parallel").unwrap(); - let spec_path = temp_dir.path().join("spec.mcrl2"); - let lps_path = temp_dir.path().join("spec.lps"); - - random_test(20, |rng| { - let spec = random_lps(rng, 5, 3, 0.4); - std::fs::write(&spec_path, spec.to_string()).expect("Failed to write random LPS spec"); - - let status = traced_command(Command::new(&txt2lps).arg(&spec_path).arg(&lps_path)) - .expect("Failed to execute txt2lps"); - assert!(status.success(), "txt2lps failed with status: {status}"); - - assert_parallel_matches_sequential(&lps_path); - }); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_random_lps() { - compare_random_lps_with_lps2lts(CachingStrategy::None); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_random_lps_local_cache() { - compare_random_lps_with_lps2lts(CachingStrategy::Local); - } -} diff --git a/tools/mcrl2/lps/src/main.rs b/tools/mcrl2/lps/src/main.rs index 227bc3464..94f2e2eb3 100644 --- a/tools/mcrl2/lps/src/main.rs +++ b/tools/mcrl2/lps/src/main.rs @@ -25,17 +25,10 @@ use mcrl2::read_lps_text; use mcrl2::set_reporting_level; use mcrl2::verbosity_to_log_level; -use explore_explicit::Mcrl2MultiActionLabel; -use explore_explicit::explore_lps_explicit; -use explore_explicit::explore_lps_explicit_parallel; -use explore_symbolic::explore_lps_symbolic; - -mod cfg_lps; -mod cfg_lps_test; -mod control_flow; -mod explore_explicit; -mod explore_lps_test; -mod explore_symbolic; +use merc_lps::Mcrl2MultiActionLabel; +use merc_lps::explore_lps_explicit; +use merc_lps::explore_lps_explicit_parallel; +use merc_lps::explore_lps_symbolic; /// Default number of nodes for the Oxidd LDD manager. const DEFAULT_OXIDD_NODE_CAPACITY: usize = 1 << 24; diff --git a/tools/mcrl2/pbes/Cargo.toml b/tools/mcrl2/pbes/Cargo.toml index d0f44b643..ccdcdff15 100644 --- a/tools/mcrl2/pbes/Cargo.toml +++ b/tools/mcrl2/pbes/Cargo.toml @@ -5,13 +5,16 @@ edition.workspace = true license.workspace = true rust-version.workspace = true +[[bin]] +name = "merc-pbes" +path = "src/main.rs" +# Avoid a rustdoc output filename collision with the `merc_pbes` library crate. +doc = false + [dependencies] mcrl2.workspace = true -merc_collections.workspace = true -merc_explore.workspace = true -merc_io.workspace = true -merc_lts.workspace = true -merc_symbolic.workspace = true +merc_explore = { workspace = true, features = ["clap"] } +merc_pbes.workspace = true merc_tools.workspace = true merc_unsafety.workspace = true merc_utilities.workspace = true @@ -19,12 +22,7 @@ merc_vpg = { workspace = true, features = ["clap"] } oxidd.workspace = true clap.workspace = true +duct.workspace = true env_logger.workspace = true -itertools.workspace = true log.workspace = true -rand.workspace = true -rayon.workspace = true -serde_json.workspace = true -serde.workspace = true -streaming-iterator.workspace = true -thiserror.workspace = true \ No newline at end of file +which.workspace = true diff --git a/tools/mcrl2/pbes/README.md b/tools/mcrl2/pbes/README.md new file mode 100644 index 000000000..74e84eb50 --- /dev/null +++ b/tools/mcrl2/pbes/README.md @@ -0,0 +1,83 @@ +# Overview + +This crate provides a command-line tool for working with Parameterised Boolean +Equation Systems (PBESs) from the mCRL2 toolset. It supports explicit and +symbolic exploration into parity games, symmetry detection, and solving. + +The `graph-symmetry` subcommand constructs the Symmetry Detection Graph (SDG) +of the PBES and calls an external [GAP](https://www.gap-system.org/) process to +compute the automorphism group. Both GAP itself and its **Digraphs** package +must be installed. + +Use `--dot ` to write the SDG as a Graphviz DOT file for +visualization. If the `dot` binary (part of [Graphviz](https://graphviz.org/)) +is on `$PATH`, a PDF is generated automatically alongside it. To convert +manually: + +```sh +dot -Tpdf file.dot -o file.pdf +dot -Tsvg file.dot -o file.svg +``` + +### DOT vertex legend + +| Shape | Fill colour | Label | Meaning | +|---|---|---|---| +| Rectangle | Blue | parameter name | PBES parameter | +| Tiny diamond (unlabelled) | Grey | — | Update vertex `X_{i,k}` | +| Hexagon | Light blue | `X` | Propositional variable instantiation of `X` | +| Parallelogram | Purple | `forall x:D,…` | Quantifier | +| Ellipse | Yellow | function name | Data function application | +| Ellipse | Orange | number | Machine-number constant | +| Ellipse | Red | `&&` / `\|\|` / `!` / `=>` | Boolean connective | +| Ellipse | Green | `x` | Bound (quantifier-scoped) variable | + +Edges between formula nodes are solid; edges are unlabelled for commutative and +flat operators (e.g. `&&`, `||`) and carry a 1-based position label for +non-commutative applications. Update edges are dashed and drawn with zero spring +weight so they do not distort the formula-tree layout. + +For a detailed description of the SDG construction and its relation to the +technical report, see the +[merc-pbes graph-symmetry](https://MERCorg.github.io/merc-website/tools/merc-pbes-graph-symmetry/) +page on the MERC website. + +## Installing GAP + +Download and install GAP 4 from . The `gap` +binary must be on `$PATH`, or its location passed via `--gap-path`. + +Verified against **GAP 4.12.1**. + +## Installing the Digraphs package + +The Digraphs package is not bundled with all GAP distributions. Install it from + or, if your GAP installation includes +the package manager, run inside a GAP session: + +```gap +InstallPackage("digraphs"); +``` + +To verify that the package loads correctly: + +```gap +LoadPackage("digraphs"); +``` + +If this returns `fail`, `graph-symmetry` will report an error pointing to the +Digraphs website. + +## Safety + +This crate contains no `unsafe` code. + +## Minimum Supported Rust Version + +The minimum supported Rust version is **1.91.0**. + +## License + +All MERC crates are licensed under the `BSL-1.0` license. See the +[LICENSE](https://raw.githubusercontent.com/MERCorg/merc/refs/heads/main/LICENSE) +file in the repository root for more information. diff --git a/tools/mcrl2/pbes/src/explore_srf_test.rs b/tools/mcrl2/pbes/src/explore_srf_test.rs deleted file mode 100644 index bc1e82f19..000000000 --- a/tools/mcrl2/pbes/src/explore_srf_test.rs +++ /dev/null @@ -1,318 +0,0 @@ -#[cfg(test)] -mod tests { - use std::path::Path; - use std::process::Command; - - use mcrl2::Pbes; - use merc_explore::CachingStrategy; - use merc_explore::ExplorationStrategy; - use merc_io::temp_dir; - use merc_io::traced_command; - use merc_vpg::Set; - use merc_vpg::solve_zielonka; - - use crate::explore_srf::parity_game_from_pbes; - use crate::explore_srf::parity_game_from_pbes_parallel; - - fn pbessolve_result(pbessolve: &Path, pbes_path: &Path) -> bool { - let output = Command::new(pbessolve) - .arg(pbes_path) - .output() - .expect("Failed to execute pbessolve"); - assert!( - output.status.success(), - "pbessolve failed with status: {}", - output.status - ); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let combined = format!("{stdout}{stderr}"); - if combined.to_lowercase().contains("true") { - true - } else if combined.to_lowercase().contains("false") { - false - } else { - panic!( - "pbessolve produced unexpected output for {}: stdout={stdout:?} stderr={stderr:?}", - pbes_path.display() - ) - } - } - - /// Converts a text PBES with `txt2pbes`, solves it with our parity game solver, - /// and asserts the result matches `pbessolve`. - fn compare_text_pbes_with_pbessolve(text_pbes_relative_path: &str) { - compare_text_pbes_with_pbessolve_caching(text_pbes_relative_path, CachingStrategy::None); - } - - /// Like [`compare_text_pbes_with_pbessolve`] but explores the PBES with the - /// given [`CachingStrategy`]. - fn compare_text_pbes_with_pbessolve_caching(text_pbes_relative_path: &str, caching: CachingStrategy) { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let txt2pbes = Path::new(&mcrl2_path).join("txt2pbes"); - let pbessolve = Path::new(&mcrl2_path).join("pbessolve"); - - let text_pbes_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(text_pbes_relative_path); - assert!( - text_pbes_path.exists(), - "Text PBES file not found: {}", - text_pbes_path.display() - ); - - let temp = temp_dir("test_explore_srf").unwrap(); - let pbes_path = temp.path().join("spec.pbes"); - - let status = traced_command(Command::new(&txt2pbes).arg(&text_pbes_path).arg(&pbes_path)) - .expect("Failed to execute txt2pbes"); - assert!(status.success(), "txt2pbes failed with status: {status}"); - - let reference = pbessolve_result(&pbessolve, &pbes_path); - - let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); - let game = - parity_game_from_pbes(&pbes, ExplorationStrategy::Bfs, caching).expect("Failed to build parity game"); - let (solution, _) = solve_zielonka(&game, false); - let result = solution[0][0]; - - assert_eq!( - result, reference, - "PBES solution mismatch for {text_pbes_relative_path}: our solver says {result}, pbessolve says {reference}" - ); - } - - /// Generates a PBES from an mCRL2 spec and a modal formula using `mcrl22lps` - /// and `lps2pbes`, then compares our solver's result with `pbessolve`. - fn compare_mcrl2_spec_with_pbessolve(spec_relative_path: &str, formula_relative_path: &str) { - compare_mcrl2_spec_with_pbessolve_caching(spec_relative_path, formula_relative_path, CachingStrategy::None); - } - - /// Like [`compare_mcrl2_spec_with_pbessolve`] but explores the PBES with the - /// given [`CachingStrategy`]. - fn compare_mcrl2_spec_with_pbessolve_caching( - spec_relative_path: &str, - formula_relative_path: &str, - caching: CachingStrategy, - ) { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let mcrl22lps = Path::new(&mcrl2_path).join("mcrl22lps"); - let lps2pbes = Path::new(&mcrl2_path).join("lps2pbes"); - let pbessolve = Path::new(&mcrl2_path).join("pbessolve"); - - let spec_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(spec_relative_path); - let formula_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(formula_relative_path); - assert!(spec_path.exists(), "Spec file not found: {}", spec_path.display()); - assert!( - formula_path.exists(), - "Formula file not found: {}", - formula_path.display() - ); - - let temp = temp_dir("test_explore_srf").unwrap(); - let lps_path = temp.path().join("spec.lps"); - let pbes_path = temp.path().join("spec.pbes"); - - let status = traced_command(Command::new(&mcrl22lps).arg(&spec_path).arg(&lps_path)) - .expect("Failed to execute mcrl22lps"); - assert!(status.success(), "mcrl22lps failed with status: {status}"); - - let status = traced_command( - Command::new(&lps2pbes) - .arg("-f") - .arg(&formula_path) - .arg(&lps_path) - .arg(&pbes_path), - ) - .expect("Failed to execute lps2pbes"); - assert!(status.success(), "lps2pbes failed with status: {status}"); - - let reference = pbessolve_result(&pbessolve, &pbes_path); - - let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); - let game = - parity_game_from_pbes(&pbes, ExplorationStrategy::Bfs, caching).expect("Failed to build parity game"); - let (solution, _) = solve_zielonka(&game, false); - let result = solution[0][0]; - - assert_eq!( - result, reference, - "PBES solution mismatch for {spec_relative_path} with {formula_relative_path}: \ - our solver says {result}, pbessolve says {reference}" - ); - } - - /// Builds the parity game from `pbes_path` both sequentially and with the - /// parallel BFS on several threads, and asserts they solve to the same winner - /// for the initial vertex (vertex 0 is the initial state in both, even though - /// the remaining numbering differs). - fn assert_parallel_matches_sequential_pbes(pbes_path: &Path) { - let pbes = Pbes::from_file(pbes_path.to_str().unwrap()).expect("Failed to read PBES"); - - let sequential = parity_game_from_pbes(&pbes, ExplorationStrategy::Bfs, CachingStrategy::None) - .expect("Sequential exploration failed"); - let (sequential_solution, _) = solve_zielonka(&sequential, false); - - // The parallel explorer must agree with the sequential one regardless of - // the caching strategy in effect, so check both. - for caching in [CachingStrategy::None, CachingStrategy::Local] { - assert_parallel_caching_matches_sequential(pbes_path, &pbes, &sequential_solution, caching); - } - } - - /// Helper for [`assert_parallel_matches_sequential_pbes`]: builds the parity - /// game with the parallel BFS under the given `caching` strategy and asserts - /// it solves to the same winner for the initial vertex. - fn assert_parallel_caching_matches_sequential( - pbes_path: &Path, - pbes: &Pbes, - sequential_solution: &[Set; 2], - caching: CachingStrategy, - ) { - let parallel = parity_game_from_pbes_parallel(pbes, 4, caching, false).expect("Parallel exploration failed"); - - // The parallel explorer numbers vertices sparsely (see `explore_parallel`), - // so its game has extra unreachable deadlock vertices and thus more - // vertices and edges. They cannot change the winner of the initial vertex, - // so we compare on the solution rather than on exact counts. - let (parallel_solution, _) = solve_zielonka(¶llel, false); - assert_eq!( - parallel_solution[0][0], - sequential_solution[0][0], - "Parallel and sequential solutions differ for {}", - pbes_path.display() - ); - } - - /// Converts a text PBES with `txt2pbes` and asserts parallel and sequential - /// parity-game construction agree. - fn compare_parallel_text_pbes(text_pbes_relative_path: &str) { - let Ok(mcrl2_path) = std::env::var("MCRL2_PATH") else { - println!("Skipping test: MCRL2_PATH not set"); - return; - }; - - let txt2pbes = Path::new(&mcrl2_path).join("txt2pbes"); - let text_pbes_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(text_pbes_relative_path); - assert!( - text_pbes_path.exists(), - "Text PBES file not found: {}", - text_pbes_path.display() - ); - - let temp = temp_dir("test_explore_srf_parallel").unwrap(); - let pbes_path = temp.path().join("spec.pbes"); - - let status = traced_command(Command::new(&txt2pbes).arg(&text_pbes_path).arg(&pbes_path)) - .expect("Failed to execute txt2pbes"); - assert!(status.success(), "txt2pbes failed with status: {status}"); - - assert_parallel_matches_sequential_pbes(&pbes_path); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_parallel_a_text_pbes() { - compare_parallel_text_pbes("../../../examples/pbes/a.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_parallel_b_text_pbes() { - compare_parallel_text_pbes("../../../examples/pbes/b.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_parallel_c_text_pbes() { - compare_parallel_text_pbes("../../../examples/pbes/c.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_a_text_pbes() { - compare_text_pbes_with_pbessolve("../../../examples/pbes/a.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_b_text_pbes() { - compare_text_pbes_with_pbessolve("../../../examples/pbes/b.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_c_text_pbes() { - compare_text_pbes_with_pbessolve("../../../examples/pbes/c.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_par_nodeadlock() { - compare_mcrl2_spec_with_pbessolve( - "../../../examples/mCRL2/academic/par/par.mcrl2", - "../../../examples/mCRL2/academic/par/nodeadlock.mcf", - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_allow_nodeadlock() { - compare_mcrl2_spec_with_pbessolve( - "../../../examples/mCRL2/academic/allow/allow.mcrl2", - "../../../examples/mCRL2/academic/allow/nodeadlock.mcf", - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_block_nodeadlock() { - compare_mcrl2_spec_with_pbessolve( - "../../../examples/mCRL2/academic/block/block.mcrl2", - "../../../examples/mCRL2/academic/block/nodeadlock.mcf", - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_abp() { - compare_mcrl2_spec_with_pbessolve( - "../../../examples/mCRL2/academic/abp/abp.mcrl2", - "../../../examples/mCRL2/academic/abp/infinitely_often_enabled_then_infinitely_often_taken.mcf", - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_cabp() { - compare_mcrl2_spec_with_pbessolve( - "../../../examples/mCRL2/academic/cabp/cabp.mcrl2", - "../../../examples/mCRL2/academic/cabp/infinitely_often_enabled_then_infinitely_often_taken.mcf", - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_abp_local_cache() { - compare_mcrl2_spec_with_pbessolve_caching( - "../../../examples/mCRL2/academic/abp/abp.mcrl2", - "../../../examples/mCRL2/academic/abp/infinitely_often_enabled_then_infinitely_often_taken.mcf", - CachingStrategy::Local, - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_mcrl2_dining3_nodeadlock() { - compare_mcrl2_spec_with_pbessolve( - "../../../examples/mCRL2/academic/dining/dining3.mcrl2", - "../../../examples/mCRL2/academic/dining/nodeadlock.mcf", - ); - } -} diff --git a/tools/mcrl2/pbes/src/explore_symbolic_srf_test.rs b/tools/mcrl2/pbes/src/explore_symbolic_srf_test.rs deleted file mode 100644 index 78f6ec7b6..000000000 --- a/tools/mcrl2/pbes/src/explore_symbolic_srf_test.rs +++ /dev/null @@ -1,60 +0,0 @@ -#[cfg(test)] -mod tests { - use std::path::Path; - - use mcrl2::Pbes; - use merc_explore::CachingStrategy; - use merc_explore::ExplorationStrategy; - use merc_utilities::Timing; - use merc_vpg::PG; - - use crate::explore_srf::parity_game_from_pbes; - use crate::explore_symbolic_srf::explore_pbes_symbolic; - - /// Reads a textual PBES, explores it both explicitly (into a parity game) - /// and symbolically (into an LDD), and asserts the number of reachable BES - /// equations agrees. The explicit parity game has exactly one vertex per - /// reachable equation, so its vertex count must equal the symbolic state - /// count. - fn assert_symbolic_matches_explicit(text_pbes_relative_path: &str) { - let text_pbes_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(text_pbes_relative_path); - assert!( - text_pbes_path.exists(), - "Text PBES file not found: {}", - text_pbes_path.display() - ); - - let pbes = Pbes::from_text_file(text_pbes_path.to_str().unwrap()).expect("Failed to read text PBES"); - - let game = parity_game_from_pbes(&pbes, ExplorationStrategy::Bfs, CachingStrategy::None) - .expect("Failed to build parity game"); - - let storage = oxidd::ldd::new_manager(1 << 20, 1 << 20, 1); - let timing = Timing::new(); - let states = explore_pbes_symbolic(&storage, &pbes, &timing).expect("Failed to explore PBES symbolically"); - - assert_eq!( - states.len() as usize, - game.num_of_vertices(), - "Symbolic state count and explicit vertex count differ for {text_pbes_relative_path}" - ); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_symbolic_a_text_pbes() { - assert_symbolic_matches_explicit("../../../examples/pbes/a.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_symbolic_b_text_pbes() { - assert_symbolic_matches_explicit("../../../examples/pbes/b.text.pbes"); - } - - #[test] - #[cfg_attr(miri, ignore)] - fn test_symbolic_c_text_pbes() { - assert_symbolic_matches_explicit("../../../examples/pbes/c.text.pbes"); - } -} diff --git a/tools/mcrl2/pbes/src/export.rs b/tools/mcrl2/pbes/src/export.rs deleted file mode 100644 index 1309021a5..000000000 --- a/tools/mcrl2/pbes/src/export.rs +++ /dev/null @@ -1,471 +0,0 @@ -use std::collections::BTreeMap; -use std::io::Write; - -use log::debug; -use mcrl2::DataExpression; -use mcrl2::DataExpressionRef; -use mcrl2::DataVariable; -use mcrl2::Pbes; -use mcrl2::PbesExpressionRef; -use mcrl2::PbesExpressionVisitor; -use mcrl2::PbesPropositionalVariableInstantiation; -use mcrl2::SrfEquation; -use mcrl2::SrfSummand; -use mcrl2::free_variables_data_expression; -use mcrl2::is_pbes_propositional_variable_instantiation; -use merc_utilities::MercError; - -use crate::symmetry::SymmetryAlgorithm; -use crate::symmetry::variable_index; - -/// Exports the information from the given PBES and its stategraph output in -/// JSON format to the given writer. -/// -/// # Details -/// -/// The output contains the identification of the control flow and data -/// parameters, and a mapping from clauses of each equation to the parameters -/// that are used-for, used-in and changed-by them. -pub(crate) fn export(write: &mut W, pbes: &Pbes) -> Result<(), MercError> { - let symmetries = SymmetryAlgorithm::new(pbes, false)?; - - let parameters = if let Some(equation) = symmetries.srf_pbes().equations().first() { - equation.variable().parameters().to_vec() - } else { - // There are no equations, so no parameters. - Vec::new() - }; - - // Figure out the control flow parameters. - let all_control_flow_parameters = symmetries - .state_graph() - .control_flow_graphs() - .iter() - .map(variable_index) - .collect::>(); - - // Figure out the data parameters by filtering out the control flow parameters. - let data_parameters: Vec = parameters - .iter() - .enumerate() - .filter_map(|(index, _param)| { - if all_control_flow_parameters.contains(&index) { - // Skip control flow parameters. - None - } else { - Some(index) - } - }) - .collect(); - - // The data variables corresponding to the data parameters. - let data_variables: Vec = data_parameters.iter().map(|index| parameters[*index].clone()).collect(); - - let mut mapping = BTreeMap::from_iter( - parameters - .iter() - .enumerate() - .map(|(index, param)| (index, param.name().to_string())), - ); - - let mut clauses = BTreeMap::new(); - let mut clause_indices = Vec::new(); - let mut unique_index = mapping.len(); - - // Keep track of the variable mappings derived from the SRF pbes. - let mut uf = BTreeMap::new(); - let mut ui = BTreeMap::new(); - let mut cb = BTreeMap::new(); - - // Keep track of the syntax trees of the condition and updates of each clause. - let mut expressions = BTreeMap::new(); - - for equation in symmetries.srf_pbes().equations() { - for (clause_index, clause) in equation.summands().iter().enumerate() { - clauses.insert((equation.variable().name().to_string(), clause_index), unique_index); - clause_indices.push(unique_index); - mapping.insert( - unique_index, - format!("{}[{}]", equation.variable().name(), clause_index), - ); - - // Compute used-for and map the variables back to their position in the variables. - let mut used_for_indices: Vec = used_for(clause, &data_variables) - .iter() - .map(|var| { - parameters - .iter() - .position(|param| param.name() == var.name()) - .expect("variable must exist in unified parameters") - }) - .collect(); - used_for_indices.sort_unstable(); - used_for_indices.dedup(); - - uf.insert(unique_index, used_for_indices); - - // Compute used-in and map the variables back to their position in the variables. - ui.insert( - unique_index, - used_in(equation, clause, &data_variables) - .iter() - .map(|var| { - parameters - .iter() - .position(|param| param.name() == var.name()) - .expect("variable must exist in unified parameters") - }) - .collect(), - ); - - // Compute changed-by and map the variables back to their position in the variables. - cb.insert( - unique_index, - changed_by(equation, clause, &data_variables) - .iter() - .map(|var| { - parameters - .iter() - .position(|param| param.name() == var.name()) - .expect("variable must exist in unified parameters") - }) - .collect(), - ); - - // Collect the syntax trees of the condition and the updates (i.e. the - // arguments of the propositional variable instantiation) of the clause. - let pvi: PbesPropositionalVariableInstantiation = clause.variable().into(); - expressions.insert( - unique_index, - ClauseExpressions { - condition: clause.condition().into(), - updates: pvi.arguments().iter().map(|update| update.protect().into()).collect(), - }, - ); - - unique_index += 1; - } - } - - debug!("Clauses {:?}", clauses); - - // Keep track of the source or target, and copy variables for each clause. - let mut src_tgt = BTreeMap::new(); - let mut copy = BTreeMap::new(); - - for equation in symmetries.state_graph().equations() { - for (clause_index, predicate) in equation.predicate_variables().iter().enumerate() { - let clause_index = *clauses - .get(&(equation.variable().name().to_string(), clause_index)) - .expect("Clause must have been added before"); - - // Update the index for the source or target variables. - for variable in predicate.source().iter().chain(predicate.target().iter()) { - if data_parameters.contains(variable) { - // This variable is a data parameter, so we are not interested in it for the source and target functions. - continue; - } - - let vector = src_tgt.entry(*variable).or_insert_with(Vec::new); - - if !vector.contains(&clause_index) { - vector.push(clause_index); - } - } - - for variable in predicate.copy().iter() { - if data_parameters.contains(variable) { - // This variable is a data parameter, we not interested in it for the copy function. - continue; - } - - let vector = copy.entry(*variable).or_insert_with(Vec::new); - - if !vector.contains(&clause_index) { - vector.push(clause_index); - } - } - } - } - - let symmetry_cliques = symmetries.cliques(); - let mut cliques = BTreeMap::new(); - for (clique_index, clique) in symmetry_cliques.iter().enumerate() { - for parameter_index in clique.iter() { - cliques.insert( - all_control_flow_parameters[*parameter_index], - format!("clique{}", clique_index), - ); - } - } - - let mut next_clique_index = symmetry_cliques.len(); - for parameter_index in &all_control_flow_parameters { - if cliques.contains_key(parameter_index) { - continue; - } - - cliques.insert(*parameter_index, format!("clique{}", next_clique_index)); - next_clique_index += 1; - } - - let output = Output { - mapping, - cliques, - - pars: (0..parameters.len()).collect(), - - clauses: clause_indices, - - cfp: all_control_flow_parameters.clone(), - - dp: data_parameters.clone(), - - uf, - ui, - cb, - - expressions, - - src_tgt, - copy, - }; - - serde_json::to_writer_pretty(write, &output)?; - Ok(()) -} - -/// Returns the data variables that are used for the given clause, i.e. the data -/// variables that occur in the condition of the clause. -/// -/// Given clause `j`, `used_for(j)` hold iff `d_k` in `fv(f_j)` for some data variable -/// `d_k`. -fn used_for(clause: &SrfSummand, data_variables: &[DataVariable]) -> Vec { - free_variables_pbes_expression(&clause.condition().copy()) - .into_iter() - .filter(|variable| data_variables.contains(variable)) - .collect() -} - -/// returns the data variables that are used in a given clause, i.e., there is -/// an update that contains the variable. -/// -/// A data variable `d_k` is used in a clause `j` iff there is some `l <= n` such that `d_k` in `fv(g_j,l(d,e_j))` where if `X = X_j` then `k != l`. -fn used_in(equation: &SrfEquation, clause: &SrfSummand, data_variables: &[DataVariable]) -> Vec { - debug_assert!( - is_pbes_propositional_variable_instantiation(&clause.variable()), - "The clause variable must always be a PVI" - ); - - let pvi: PbesPropositionalVariableInstantiation = clause.variable().into(); - - // When the clause targets its own equation (`X == X_j`) the variable's own - // update (`var_index == update_index`) must be excluded below. - let is_self_recursive = pvi.name() == equation.variable().name().copy(); - - // We assume that all equations have the same parameters, so we can just use the parameters of the given equation. - let params = equation.variable().parameters(); - let args = pvi.arguments(); - debug_assert_eq!( - params.len(), - args.iter().count(), - "used_in: parameters and arguments must have the same length" - ); - - let mut result = Vec::new(); - for (var_index, variable) in params.iter().enumerate() { - if !data_variables.contains(&variable) { - // Only data variables are of interest. - continue; - } - - for (update_index, update) in args.iter().enumerate() { - if is_self_recursive && var_index == update_index { - // Exclude the variable's own update for self-recursive clauses. - continue; - } - - if free_variables_data_expression(&update.copy()).contains(&variable) { - // This variable is used in at least one update of the clause. - result.push(variable); - break; - } - } - } - - result -} - -/// Returns the data variables that are changed by a given clause, i.e., there is an update that contains the variable and the variable is updated to a different value. -/// -/// A data variable `d_k` is changed by a clause `j` if `X = X_j` and `d_k` != `g_j,k(d, e_j)`. -fn changed_by(equation: &SrfEquation, clause: &SrfSummand, data_variables: &[DataVariable]) -> Vec { - debug_assert!( - is_pbes_propositional_variable_instantiation(&clause.variable()), - "The clause variable must always be a PVI" - ); - - let pvi: PbesPropositionalVariableInstantiation = clause.variable().into(); - - let mut result = Vec::new(); - if pvi.name() == equation.variable().name().copy() { - // X = X_j, so we need to check which variables are changed by the clause. - let params = equation.variable().parameters(); - let args = pvi.arguments(); - debug_assert_eq!( - params.len(), - args.iter().count(), - "changed_by: parameters and arguments must have the same length" - ); - - for (variable, update) in params.iter().zip(args.iter()) { - if !data_variables.contains(&variable) { - // Only data variables are of interest. - continue; - } - - if Into::>::into(variable.copy()) != update.copy() { - // This variable is changed by the clause. - result.push(variable); - } - } - } - - result -} - -/// Returns all the data variables occurring in the given PBES expression. -fn free_variables_pbes_expression(expr: &PbesExpressionRef<'_>) -> Vec { - let mut result = Vec::new(); - - /// Local struct that is used to collect data variable occurrences. - struct FreeVariableOccurrences<'a> { - result: &'a mut Vec, - } - - impl PbesExpressionVisitor for FreeVariableOccurrences<'_> { - fn visit_data_expression(&mut self, expr: &mcrl2::DataExpressionRef<'_>) -> Option { - self.result.extend(free_variables_data_expression(expr)); - None - } - } - - let mut occurrences = FreeVariableOccurrences { result: &mut result }; - occurrences.visit(expr); - result -} - -/// The output gathered to be exported in JSON format. -#[derive(serde::Serialize)] -struct Output { - /// Stores the mapping from indices to parameters names. - mapping: BTreeMap, - - /// Stores the indices of parameters (used in the uf, ui and cb fields). - pars: Vec, - - /// Stores indices for clauses - clauses: Vec, - - /// The control flow parameter indices in `mapping`. - cfp: Vec, - - /// The data parameter indices in `mapping`. - dp: Vec, - - /// Maps from clause indices to the parameter indices that are used for. - uf: BTreeMap>, - - /// Maps from clause indices to the parameter indices that are used in. - ui: BTreeMap>, - - /// Maps from clause indices to the parameter indices that are changed by the clause. - cb: BTreeMap>, - - /// Maps from clause indices to the syntax trees of the condition and updates of that clause. - expressions: BTreeMap, - - /// Maps from parameter indices to the clause indices where they occur as source or target variables. - src_tgt: BTreeMap>, - - /// Maps from parameter indices to the clause indices where they occur as copy variables. - copy: BTreeMap>, - - /// a mapping from control flow parameter indices to the clique they belong to. - cliques: BTreeMap, -} - -/// The syntax trees of the condition and the updates of a single clause. -#[derive(serde::Serialize)] -struct ClauseExpressions { - /// The syntax tree of the condition of the clause. - condition: DataExpression, - - /// The syntax trees of the updates of the clause, i.e. one syntax tree for - /// each argument of the propositional variable instantiation. - updates: Vec, -} - -#[cfg(test)] -mod tests { - use std::fs; - use std::io::ErrorKind; - - use mcrl2::Pbes; - - use crate::export::export; - - /// Helper function to assert that the exported JSON matches the expected snapshot. - fn assert_export_matches_snapshot(input: &str, expected_path: &str) { - let input = Pbes::from_text(input).unwrap(); - - let mut buffer = Vec::new(); - export(&mut buffer, &input).unwrap(); - - let output = String::from_utf8(buffer).unwrap(); - let expected = match fs::read_to_string(expected_path) { - Ok(content) => content, - Err(err) if err.kind() == ErrorKind::NotFound => { - // The snapshot did not exist: write the current output and fail - // loudly so a missing snapshot never silently "passes". Re-run the - // test (and commit the snapshot) to verify the generated output. - fs::write(expected_path, &output).unwrap(); - panic!("Snapshot {expected_path} did not exist; wrote current output. Re-run to verify."); - } - Err(err) => panic!("Failed to read snapshot {}: {}", expected_path, err), - }; - - // Normalize line endings for cross-platform comparison - let output_normalized = output.replace("\r\n", "\n"); - let expected_normalized = expected.replace("\r\n", "\n"); - - assert_eq!( - output_normalized, expected_normalized, - "The exported JSON does not match the expected output" - ); - } - - #[test] - fn test_a_text_pbes_export() { - assert_export_matches_snapshot( - include_str!("../../../../examples/pbes/a.text.pbes"), - "src/snapshots/a.text.pbes.json", - ); - } - - #[test] - fn test_b_text_pbes_export() { - assert_export_matches_snapshot( - include_str!("../../../../examples/pbes/b.text.pbes"), - "src/snapshots/b.text.pbes.json", - ); - } - - #[test] - fn test_c_text_pbes_export() { - assert_export_matches_snapshot( - include_str!("../../../../examples/pbes/c.text.pbes"), - "src/snapshots/c.text.pbes.json", - ); - } -} diff --git a/tools/mcrl2/pbes/src/main.rs b/tools/mcrl2/pbes/src/main.rs index 50896f02e..bcae3d8d5 100644 --- a/tools/mcrl2/pbes/src/main.rs +++ b/tools/mcrl2/pbes/src/main.rs @@ -1,9 +1,15 @@ +use std::fs::File; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; use std::process::ExitCode; +use std::sync::Arc; use clap::Parser; use clap::Subcommand; use log::debug; use log::info; +use log::warn; use mcrl2::Pbes; use mcrl2::set_reporting_level; @@ -11,37 +17,50 @@ use mcrl2::verbosity_to_log_level; use merc_tools::VerbosityFlag; use merc_tools::Version; use merc_tools::VersionFlag; +use merc_tools::format_key_values_json; use merc_tools::report_error; +use merc_unsafety::print_allocator_metrics; use merc_utilities::MercError; use merc_utilities::Timing; -use crate::explore_srf::parity_game_from_pbes; -use crate::explore_srf::parity_game_from_pbes_parallel; -use crate::explore_symbolic_srf::explore_pbes_symbolic; -use crate::permutation::Permutation; -use crate::symmetry::SymmetryAlgorithm; +use merc_pbes::Bsgs; +use merc_pbes::GapConfig; +use merc_pbes::ParameterLayoutLPS; +use merc_pbes::PbesLps; +use merc_pbes::PbesSrfLps; +use merc_pbes::PbesVertex; +use merc_pbes::Permutation; +use merc_pbes::QuotientLps; +use merc_pbes::SymmetryAlgorithm; +use merc_pbes::check_parameter_basis; +use merc_pbes::explore_pbes; +use merc_pbes::explore_pbes_impl; +use merc_pbes::explore_pbes_parallel; +use merc_pbes::explore_pbes_parallel_impl; +use merc_pbes::explore_pbes_symbolic; +use merc_pbes::explore_srf_pbes; +use merc_pbes::explore_srf_pbes_parallel; +use merc_pbes::graph_symmetries; +use merc_pbes::symmetry_parameter_basis; +use merc_pbes::symmetry_unified_pbes; +use merc_pbes::write_dot; + +use merc_explore::CacheLPS; use merc_explore::CachingStrategy; use merc_explore::ExplorationStrategy; +use merc_explore::Summand; use merc_vpg::PG; use merc_vpg::Player; use merc_vpg::Solver; use merc_vpg::solve_priority_promotion; use merc_vpg::solve_zielonka; use merc_vpg::verify_solution; - -mod clone_iterator; -mod explore_srf; -mod explore_srf_test; -mod explore_symbolic_srf; -mod explore_symbolic_srf_test; -mod export; -mod permutation; -mod symmetry; +use merc_vpg::write_pg; /// Default number of nodes for the Oxidd LDD manager. const DEFAULT_OXIDD_NODE_CAPACITY: usize = 1 << 24; -#[derive(clap::ValueEnum, Clone, Debug)] +#[derive(clap::ValueEnum, Clone, Copy, Debug)] enum PbesFormat { Text, Pbes, @@ -60,6 +79,13 @@ struct Cli { #[arg(long, global = true)] timings: bool, + /// Skip the preprocessing that mCRL2 applies to a PBES before instantiating + /// it (instantiate global variables, simplify, one point rule, order + /// quantified variables). `pbessolve` always applies it, so leaving it on is + /// what makes the two tools comparable. + #[arg(long, global = true, default_value_t = false)] + no_preprocess: bool, + /// The number of worker threads for the Oxidd LDD manager. #[arg(long, global = true, default_value_t = 1)] oxidd_workers: u32, @@ -87,20 +113,23 @@ fn init_ldd_manager(cli: &Cli) -> oxidd::ldd::LDDManagerRef { #[derive(Debug, Subcommand)] enum Commands { + /// Print a PBES in textual format. + Print(PrintArgs), /// Analyze symmetries of a PBES Symmetry(SymmetryArgs), - /// Exports the control flow graphs of a PBES in JSON format. - Export(ExportArgs), + /// Compute symmetries of a PBES via the Symmetry Detection Graph and GAP. + GraphSymmetry(GraphSymmetryArgs), /// Explore a PBES explicitly into a parity game. ExploreExplicit(ExploreExplicitArgs), /// Explore a PBES symbolically using LDD-based reachability. - ExploreSymbolic(ExploreSymbolicArgs), + ExploreSymbolic(InputArgs), /// Solve a PBES by exploring it into a parity game and solving the game. Solve(SolveArgs), } +/// The PBES to read, shared by every subcommand. #[derive(clap::Args, Debug)] -struct SymmetryArgs { +struct InputArgs { /// The input PBES file. filename: String, @@ -108,6 +137,76 @@ struct SymmetryArgs { #[arg(long, short('i'), value_enum)] format: Option, + /// Write the PBES that the symmetry generators are numbered against, after + /// preprocessing and unification. + #[arg(long, value_name = "FILE")] + dump_unified_pbes: Option, +} + +/// How to turn a PBES into a parity game, shared by every subcommand that +/// explores one. `solve` is `explore-explicit` followed by solving the resulting +/// game, so both accept exactly these flags. +#[derive(clap::Args, Debug)] +struct ExploreArgs { + /// Strategy to explore the state space of the PBES. Only used for sequential + /// exploration; ignored when `--threads > 1` (the parallel explorer always + /// uses a level-synchronised BFS). + #[arg(long, value_enum, default_value_t = ExplorationStrategy::Bfs)] + strategy: ExplorationStrategy, + + /// Caching strategy to use during exploration. + #[arg(long, value_enum, default_value_t = CachingStrategy::None)] + caching: CachingStrategy, + + /// Number of worker threads used for exploration. + #[arg(long, default_value_t = 1)] + threads: usize, + + /// Pin each worker thread round-robin to the available CPU cores. + #[arg(long, default_value_t = false)] + pinned: bool, + + /// Apply symmetry reduction: compute graph automorphisms, build a BSGS, and + /// canonicalize every next-state to its orbit representative before adding + /// it to the state space. + #[arg(long, default_value_t = false)] + symmetry: bool, + + /// Supply generators directly in mapping '[0->1,...]' or cycle '(0 1)' + /// notation to build the BSGS without running GAP symmetry detection. + /// Repeat the flag for multiple generators: `--quotient '(0 1)' --quotient '(2 3)'`. + #[arg(long, value_name = "PERM")] + quotient: Vec, + + /// Path or name of the GAP executable used to compute the BSGS (only + /// relevant when `--symmetry` or `--quotient` is set). + #[arg(long, default_value = "gap")] + gap_path: String, + + /// Convert to SRF before exploring (legacy; default is the direct structure-graph algorithm). + #[arg(long, default_value_t = false)] + srf: bool, + + /// Write the resulting parity game to this file in the PGSolver `.pg` format. + #[arg(long, short('o'), value_name = "FILE")] + output: Option, +} + +#[derive(clap::Args, Debug)] +struct PrintArgs { + #[command(flatten)] + input: InputArgs, + + /// Write the PBES to this file instead of standard output. + #[arg(long, short('o'), value_name = "FILE")] + output: Option, +} + +#[derive(clap::Args, Debug)] +struct SymmetryArgs { + #[command(flatten)] + input: InputArgs, + /// Pass a single permutation in mapping notation '[0->1,1->0,...]' or cycles notation '(0 1)' to check whether it is a symmetry. #[arg(long)] permutation: Option, @@ -134,75 +233,43 @@ struct SymmetryArgs { } #[derive(clap::Args, Debug)] -struct ExportArgs { - /// The input PBES file. - filename: String, - - /// The JSON output file. If not provided, the output will be written to stdout. - #[arg(long)] - output: Option, - - /// Explicitly choose the format of the input PBES file. - #[arg(long, short('i'), value_enum)] - format: Option, -} - -#[derive(clap::Args, Debug)] -struct ExploreExplicitArgs { - /// The input PBES file. - filename: String, - - /// Strategy to explore the state space of the PBES. Only used for sequential - /// exploration; ignored when `--threads > 1` (the parallel explorer always - /// uses a level-synchronised BFS). - #[arg(long, value_enum, default_value_t = ExplorationStrategy::Bfs)] - strategy: ExplorationStrategy, +struct GraphSymmetryArgs { + #[command(flatten)] + input: InputArgs, - /// Caching strategy to use during exploration. - #[arg(long, value_enum, default_value_t = CachingStrategy::None)] - caching: CachingStrategy, + /// Path or name of the GAP executable. + #[arg(long, default_value = "gap")] + gap_path: String, - /// Explicitly choose the format of the input PBES file. - #[arg(long, short('i'), value_enum)] - format: Option, + /// Write the generated GAP script to this file (for debugging). + #[arg(long)] + dump_gap_script: Option, - /// Number of worker threads used for exploration. - #[arg(long, default_value_t = 1)] - threads: usize, + /// Write the SDG as a Graphviz DOT file to this path. + #[arg(long)] + dot: Option, - /// Pin each worker thread round-robin to the available CPU cores. + /// Print symmetries in mapping notation instead of cycle notation. #[arg(long, default_value_t = false)] - pinned: bool, + mapping_notation: bool, } #[derive(clap::Args, Debug)] -struct ExploreSymbolicArgs { - /// The input PBES file. - filename: String, +struct ExploreExplicitArgs { + #[command(flatten)] + input: InputArgs, - /// Explicitly choose the format of the input PBES file. - #[arg(long, short('i'), value_enum)] - format: Option, + #[command(flatten)] + explore: ExploreArgs, } #[derive(clap::Args, Debug)] struct SolveArgs { - /// The input PBES file. - filename: String, - - /// Strategy to explore the state space of the PBES. Only used for sequential - /// exploration; ignored when `--threads > 1` (the parallel explorer always - /// uses a level-synchronised BFS). - #[arg(long, value_enum, default_value_t = ExplorationStrategy::Bfs)] - strategy: ExplorationStrategy, - - /// Caching strategy to use during exploration. - #[arg(long, value_enum, default_value_t = CachingStrategy::None)] - caching: CachingStrategy, + #[command(flatten)] + input: InputArgs, - /// Explicitly choose the format of the input PBES file. - #[arg(long, short('i'), value_enum)] - format: Option, + #[command(flatten)] + explore: ExploreArgs, /// Sets the algorithm used to solve the resulting parity game. #[arg(long, value_enum, default_value_t = Solver::Zielonka)] @@ -211,14 +278,6 @@ struct SolveArgs { /// Whether to verify the solution after computing it. #[arg(long, default_value_t = false)] verify_solution: bool, - - /// Number of worker threads used for exploration. - #[arg(long, default_value_t = 1)] - threads: usize, - - /// Pin each worker thread round-robin to the available CPU cores. - #[arg(long, default_value_t = false)] - pinned: bool, } fn main() -> ExitCode { @@ -226,6 +285,7 @@ fn main() -> ExitCode { env_logger::Builder::new() .filter_level(cli.verbosity.log_level_filter()) + .format_key_values(|formatter, source| format_key_values_json(formatter, source)) .parse_default_env() .init(); @@ -244,76 +304,322 @@ fn main() -> ExitCode { timing.print(); } + print_allocator_metrics(); report_error(result) } fn handle_command(cli: &Cli, timing: &Timing) -> Result<(), MercError> { + let preprocess = !cli.no_preprocess; + if let Some(command) = &cli.commands { match command { - Commands::Symmetry(args) => handle_symmetry(args)?, - Commands::Export(args) => handle_export(args)?, - Commands::ExploreExplicit(args) => handle_explore_explicit(args)?, - Commands::ExploreSymbolic(args) => handle_explore_symbolic(cli, args, timing)?, - Commands::Solve(args) => handle_solve(args)?, + Commands::Print(args) => handle_print(args, timing, preprocess)?, + Commands::Symmetry(args) => handle_symmetry(args, timing, preprocess)?, + Commands::GraphSymmetry(args) => handle_graph_symmetry(args, timing, preprocess)?, + Commands::ExploreExplicit(args) => handle_explore_explicit(args, timing, preprocess)?, + Commands::ExploreSymbolic(args) => handle_explore_symbolic(cli, args, timing, preprocess)?, + Commands::Solve(args) => handle_solve(args, timing, preprocess)?, } } Ok(()) } -/// Reads a PBES from the given file in the explicitly chosen format, or the -/// binary PBES format when no format is given. -fn read_pbes(filename: &str, format: Option) -> Result { - match format.unwrap_or(PbesFormat::Pbes) { - PbesFormat::Pbes => Ok(Pbes::from_file(filename)?), - PbesFormat::Text => Ok(Pbes::from_text_file(filename)?), +impl InputArgs { + /// Reads the PBES in the explicitly chosen format, or the binary PBES format + /// when no format is given. + /// + /// Unless `preprocess` is false, the PBES is put through the same preprocessing + /// that mCRL2 applies before instantiating one. Doing it here rather than inside + /// a single explorer keeps every consumer of this PBES — the explorers, the + /// symmetry detection and the parameter basis the generators index into — + /// looking at the same equations. + fn read(&self, timing: &Timing, preprocess: bool) -> Result { + let mut pbes = timing.measure("load PBES", || match self.format.unwrap_or(PbesFormat::Pbes) { + PbesFormat::Pbes => Pbes::from_file(&self.filename), + PbesFormat::Text => Pbes::from_text_file(&self.filename), + })?; + + if preprocess { + pbes.preprocess(timing)?; + } else { + info!("Skipping PBES preprocessing (--no-preprocess)"); + } + + if let Some(path) = &self.dump_unified_pbes { + self.dump_unified(&pbes, path)?; + } + + Ok(pbes) + } + + /// Writes the unified PBES the symmetry generators index into, and logs its + /// parameter vector with the positions a `--quotient` permutation uses. + /// + /// Unification is redone here rather than reusing the explorer's, since only + /// the symmetry path unifies at all and the dump has to be available to every + /// subcommand. + fn dump_unified(&self, pbes: &Pbes, path: &Path) -> Result<(), MercError> { + let unified = symmetry_unified_pbes(pbes)?; + write!(File::create(path)?, "{}", unified)?; + + let basis = symmetry_parameter_basis(pbes)?; + info!( + "Unified PBES written to '{}', {} parameter(s): {}", + path.display(), + basis.len(), + basis + .iter() + .enumerate() + .map(|(index, parameter)| format!("{index}: {parameter}")) + .collect::>() + .join(", ") + ); + + Ok(()) } } -fn handle_explore_explicit(args: &ExploreExplicitArgs) -> Result<(), MercError> { - let pbes = read_pbes(&args.filename, args.format.clone())?; - let game = if args.threads > 1 { - parity_game_from_pbes_parallel(&pbes, args.threads, args.caching, args.pinned)? - } else { - parity_game_from_pbes(&pbes, args.strategy, args.caching)? - }; - println!( +impl ExploreArgs { + /// Explores `pbes` into a parity game, applying symmetry reduction when + /// generators are supplied or detected, and writes the game to `--output`. + fn explore(&self, pbes: Pbes, timing: &Timing) -> Result { + // Explicit generators take precedence over detection: giving both means + // the user already knows the group and only detection would be redundant. + let bsgs = if !self.quotient.is_empty() { + Some(build_bsgs_from_user_generators( + &pbes, + &self.quotient, + &self.gap_path, + timing, + )?) + } else if self.symmetry { + Some(build_bsgs_for_pbes(&pbes, &self.gap_path, timing)?) + } else { + None + }; + + let game = if let Some(bsgs) = bsgs { + explore_with_symmetry(&pbes, self, bsgs, timing)? + } else if self.threads > 1 && self.srf { + explore_srf_pbes_parallel(&pbes, self.threads, self.caching, self.pinned, timing)? + } else if self.threads > 1 { + explore_pbes_parallel(pbes, self.threads, self.caching, self.pinned, timing)? + } else if self.srf { + explore_srf_pbes(&pbes, self.strategy, self.caching, timing)? + } else { + explore_pbes(pbes, self.strategy, self.caching, timing)? + }; + + if let Some(output) = &self.output { + let mut output_file = File::create(output)?; + write_pg(&mut output_file, &game)?; + info!("Parity game written to '{}'", output.display()); + } + + Ok(game) + } +} + +fn handle_explore_explicit(args: &ExploreExplicitArgs, timing: &Timing, preprocess: bool) -> Result<(), MercError> { + let game = args.explore.explore(args.input.read(timing, preprocess)?, timing)?; + + // Reported as log key-values so that `format_key_values_json` renders them as + // a JSON object next to the human-readable message, which makes the sizes + // machine-consumable without parsing the message text. + info!( + vertices = game.num_of_vertices(), + edges = game.num_of_edges(); "Parity game: {} vertices, {} edges", game.num_of_vertices(), game.num_of_edges() ); + + Ok(()) +} + +/// Handles the print command, writing the textual PBES to `--output` or to +/// standard output. +/// +/// Like every other subcommand this prints the PBES *after* preprocessing, so +/// that what is shown is what the explorers actually see; `--no-preprocess` +/// prints the PBES as it was read. +fn handle_print(args: &PrintArgs, timing: &Timing, preprocess: bool) -> Result<(), MercError> { + let pbes = args.input.read(timing, preprocess)?; + + if let Some(output) = &args.output { + write!(File::create(output)?, "{}", pbes)?; + info!("PBES written to '{}'", output.display()); + } else { + println!("{}", pbes); + } + Ok(()) } /// Handles symbolic exploration of a PBES, reporting the number of reachable /// BES equations (states). -fn handle_explore_symbolic(cli: &Cli, args: &ExploreSymbolicArgs, timing: &Timing) -> Result<(), MercError> { - let pbes = read_pbes(&args.filename, args.format.clone())?; +fn handle_explore_symbolic(cli: &Cli, args: &InputArgs, timing: &Timing, preprocess: bool) -> Result<(), MercError> { + let pbes = args.read(timing, preprocess)?; let storage = init_ldd_manager(cli); let states = explore_pbes_symbolic(&storage, &pbes, timing)?; println!("Number of states: {}", states.len()); Ok(()) } -/// Handles the solve command, which explores a PBES into a parity game and -/// solves the game, printing the solution of the initial vertex. -fn handle_solve(args: &SolveArgs) -> Result<(), MercError> { - let pbes = read_pbes(&args.filename, args.format.clone())?; - let game = if args.threads > 1 { - parity_game_from_pbes_parallel(&pbes, args.threads, args.caching, args.pinned)? - } else { - parity_game_from_pbes(&pbes, args.strategy, args.caching)? +/// Parse permutation strings in mapping `[0->1,...]` or cycle `(0 1)` notation. +fn parse_generators(strs: &[String]) -> Result, MercError> { + strs.iter() + .map(|s| { + let s = s.trim(); + if s.starts_with('[') { + Permutation::from_mapping_notation(s) + } else { + Permutation::from_cycle_notation(s) + } + }) + .collect() +} + +/// Build a BSGS from user-supplied generator strings without running graph-symmetry detection. +fn build_bsgs_from_user_generators( + pbes: &Pbes, + strs: &[String], + gap_path: &str, + timing: &Timing, +) -> Result, MercError> { + let config = GapConfig { + executable: gap_path.to_string(), + dump_script: None, }; + let generators = parse_generators(strs)?; + let n = symmetry_parameter_basis(pbes)?.len(); + + // Reject out-of-range points here: converting to a dense permutation would + // silently truncate them to `0..n`, producing a mapping that is no longer a + // permutation and panics when inverted. + for (generator, text) in generators.iter().zip(strs) { + if let Some(max_point) = generator.max_point() + && max_point >= n + { + return Err(MercError::from(format!( + "generator '{}' mentions parameter {max_point}, but the PBES has {n} parameter(s) (0..{})", + text.trim(), + n.saturating_sub(1) + ))); + } + } + + let bsgs = Arc::new(timing.measure("symmetry: BSGS", || Bsgs::from_generators(&generators, n, &config))?); info!( - "Parity game: {} vertices, {} edges", - game.num_of_vertices(), - game.num_of_edges() + "User-supplied generators: |G| = {} ({} generator(s))", + bsgs.order(), + generators.len() ); + Ok(bsgs) +} + +/// Compute graph symmetries for `pbes` and build a BSGS from them. +fn build_bsgs_for_pbes(pbes: &Pbes, gap_path: &str, timing: &Timing) -> Result, MercError> { + let config = GapConfig { + executable: gap_path.to_string(), + dump_script: None, + }; + let sym_result = timing.measure("symmetry: detection", || graph_symmetries(pbes, &config))?; + let n = symmetry_parameter_basis(pbes)?.len(); + let bsgs = Arc::new(timing.measure("symmetry: BSGS", || { + Bsgs::from_generators(&sym_result.generators, n, &config) + })?); + info!("|G| = {} ({} generator(s))", bsgs.order(), sym_result.generators.len()); + + // The two orders are computed by entirely separate routes — GAP's + // `Size(Stabilizer(...))` on the detection graph versus the product of the + // transversal sizes of the stabilizer chain built from the rendered + // generators — so a disagreement means a generator was rendered, parsed or + // truncated wrongly somewhere in between. The quotient stays sound either + // way (canonicalization only ever uses the chain), so warn rather than fail. + if bsgs.order() != sym_result.symmetry_group_order { + warn!( + "symmetry group order mismatch: graph symmetry detection reports |Sym(pbes)| = {}, \ + but the BSGS built from its generators has order {}; the quotient will reduce by \ + the smaller group", + sym_result.symmetry_group_order, + bsgs.order() + ); + } + Ok(bsgs) +} + +/// Explore `pbes` into a parity game, canonicalizing every next-state via `bsgs`. +fn explore_with_symmetry( + pbes: &Pbes, + args: &ExploreArgs, + bsgs: Arc, + timing: &Timing, +) -> Result { + // Both explorers unify with the same flags as `symmetry_parameter_basis`, so + // they are expected to agree with it; SRF normalisation is the one that could + // still add or reorder parameters on the way, since it introduces equations + // of its own. Checking both keeps the guarantee where it can be seen. + let basis = symmetry_parameter_basis(pbes)?; + + if args.srf { + let lps = PbesSrfLps::new(pbes)?; + check_parameter_basis(&basis, &lps.parameters(), "SRF")?; + quotient_explore(&lps, args, bsgs, timing) + } else { + let lps = PbesLps::new(pbes.clone())?; + check_parameter_basis(&basis, &lps.parameters(), "structure-graph")?; + quotient_explore(&lps, args, bsgs, timing) + } +} + +/// Explore `lps` into a parity game, canonicalizing every next-state via `bsgs`. +fn quotient_explore

( + lps: &P, + args: &ExploreArgs, + bsgs: Arc, + timing: &Timing, +) -> Result +where + P: ParameterLayoutLPS + Sync, + ::Context: Send, +{ + match args.caching { + CachingStrategy::None => { + let qlps = QuotientLps::new(lps, bsgs, 1); + if args.threads > 1 { + explore_pbes_parallel_impl(&qlps, args.threads, args.pinned, timing) + } else { + explore_pbes_impl(&qlps, args.strategy, timing) + } + } + caching => { + // The cache sits *inside* the quotient (see [`QuotientLps`]) so the + // keys stay the narrow read-position projections of the raw states + // instead of covering every parameter touched by canonicalization. + let cached = CacheLPS::new(lps, caching); + let qlps = QuotientLps::new(&cached, bsgs, 1); + let game = if args.threads > 1 { + explore_pbes_parallel_impl(&qlps, args.threads, args.pinned, timing) + } else { + explore_pbes_impl(&qlps, args.strategy, timing) + }?; + debug!("{}", cached.metrics()); + Ok(game) + } + } +} + +/// Handles the solve command, which explores a PBES into a parity game and +/// solves the game, printing the solution of the initial vertex. +fn handle_solve(args: &SolveArgs, timing: &Timing, preprocess: bool) -> Result<(), MercError> { + let game = args.explore.explore(args.input.read(timing, preprocess)?, timing)?; - let (solution, strategy) = match args.solver { + let (solution, strategy) = timing.measure("solve", || match args.solver { Solver::Zielonka => solve_zielonka(&game, args.verify_solution), Solver::PriorityPromotion => solve_priority_promotion(&game, args.verify_solution), - }; + }); if let Some(strategy) = strategy && args.verify_solution @@ -331,8 +637,43 @@ fn handle_solve(args: &SolveArgs) -> Result<(), MercError> { Ok(()) } -fn handle_symmetry(args: &SymmetryArgs) -> Result<(), MercError> { - let pbes = read_pbes(&args.filename, args.format.clone())?; +fn handle_graph_symmetry(args: &GraphSymmetryArgs, timing: &Timing, preprocess: bool) -> Result<(), MercError> { + let pbes = args.input.read(timing, preprocess)?; + + let config = GapConfig { + executable: args.gap_path.clone(), + dump_script: args.dump_gap_script.as_deref().map(Path::new).map(|p| p.to_path_buf()), + }; + + let result = timing.measure("symmetry: detection", || graph_symmetries(&pbes, &config))?; + + if let Some(dot_path) = &args.dot { + let mut f = File::create(dot_path)?; + write_dot(&result.sdg, &mut f)?; + log::info!("DOT file written to '{dot_path}'"); + if let Ok(dot_bin) = which::which("dot") { + log::info!("Generating PDF using dot..."); + duct::cmd!(dot_bin, "-Tpdf", dot_path, "-O").run()?; + } + } + + for generator in &result.generators { + if args.mapping_notation { + println!("{:?}", generator); + } else { + println!("{}", generator); + } + } + + if result.generators.is_empty() { + println!("No non-trivial symmetries found."); + } + + Ok(()) +} + +fn handle_symmetry(args: &SymmetryArgs, timing: &Timing, preprocess: bool) -> Result<(), MercError> { + let pbes = args.input.read(timing, preprocess)?; let algorithm = SymmetryAlgorithm::new(&pbes, args.print_srf)?; if let Some(permutation) = &args.permutation { let pi = if permutation.trim_start().starts_with("[") { @@ -378,19 +719,3 @@ fn handle_symmetry(args: &SymmetryArgs) -> Result<(), MercError> { Ok(()) } - -/// Handles the export command, which exports the control flow graphs of a PBES in JSON format. -fn handle_export(args: &ExportArgs) -> Result<(), MercError> { - let pbes = read_pbes(&args.filename, args.format.clone())?; - - if let Some(output_filename) = &args.output { - let mut file = std::fs::File::create(output_filename)?; - export::export(&mut file, &pbes)?; - } else { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - export::export(&mut handle, &pbes)?; - } - - Ok(()) -} diff --git a/tools/mcrl2/pbes/src/snapshots/a.text.pbes.json b/tools/mcrl2/pbes/src/snapshots/a.text.pbes.json deleted file mode 100644 index 48511462e..000000000 --- a/tools/mcrl2/pbes/src/snapshots/a.text.pbes.json +++ /dev/null @@ -1,374 +0,0 @@ -{ - "mapping": { - "0": "c1", - "1": "d1", - "2": "c2", - "3": "d2", - "4": "Y[0]", - "5": "Y[1]", - "6": "Y[2]", - "7": "X_false[0]", - "8": "X_true[0]" - }, - "pars": [ - 0, - 1, - 2, - 3 - ], - "clauses": [ - 4, - 5, - 6, - 7, - 8 - ], - "cfp": [ - 0, - 2 - ], - "dp": [ - 1, - 3 - ], - "uf": { - "4": [], - "5": [], - "6": [], - "7": [], - "8": [] - }, - "ui": { - "4": [], - "5": [ - 1 - ], - "6": [], - "7": [], - "8": [] - }, - "cb": { - "4": [ - 1 - ], - "5": [ - 1, - 3 - ], - "6": [ - 3 - ], - "7": [], - "8": [] - }, - "expressions": { - "4": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "==", - "sort": "Pos # Pos -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "1", - "sort": null, - "args": [] - } - ] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "2", - "sort": null, - "args": [] - } - ] - }, - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "d1", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d2", - "sort": "Bool", - "args": [] - } - ] - }, - "5": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "==", - "sort": "Pos # Pos -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "1", - "sort": null, - "args": [] - } - ] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "2", - "sort": null, - "args": [] - } - ] - }, - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "d1", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - } - ] - }, - "6": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "==", - "sort": "Pos # Pos -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "1", - "sort": null, - "args": [] - } - ] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "2", - "sort": null, - "args": [] - } - ] - }, - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "d2", - "sort": "Bool", - "args": [] - } - ] - } - ] - }, - "7": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "false", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d2", - "sort": "Bool", - "args": [] - } - ] - }, - "8": { - "condition": { - "symbol": "true", - "sort": "Bool", - "args": [] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d2", - "sort": "Bool", - "args": [] - } - ] - } - }, - "src_tgt": { - "0": [ - 4, - 5 - ], - "2": [ - 6 - ] - }, - "copy": { - "0": [ - 6, - 7, - 8 - ], - "2": [ - 4, - 5, - 7, - 8 - ] - }, - "cliques": { - "0": "clique0", - "2": "clique1" - } -} \ No newline at end of file diff --git a/tools/mcrl2/pbes/src/snapshots/b.text.pbes.json b/tools/mcrl2/pbes/src/snapshots/b.text.pbes.json deleted file mode 100644 index 6108ad697..000000000 --- a/tools/mcrl2/pbes/src/snapshots/b.text.pbes.json +++ /dev/null @@ -1,291 +0,0 @@ -{ - "mapping": { - "0": "c1", - "1": "d1", - "2": "c2", - "3": "d2", - "4": "Y[0]", - "5": "Y[1]", - "6": "X_false[0]", - "7": "X_true[0]" - }, - "pars": [ - 0, - 1, - 2, - 3 - ], - "clauses": [ - 4, - 5, - 6, - 7 - ], - "cfp": [ - 0, - 2 - ], - "dp": [ - 1, - 3 - ], - "uf": { - "4": [], - "5": [], - "6": [], - "7": [] - }, - "ui": { - "4": [ - 1, - 3 - ], - "5": [], - "6": [], - "7": [] - }, - "cb": { - "4": [ - 1, - 3 - ], - "5": [ - 3 - ], - "6": [], - "7": [] - }, - "expressions": { - "4": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "==", - "sort": "Pos # Pos -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "1", - "sort": null, - "args": [] - } - ] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "2", - "sort": null, - "args": [] - } - ] - }, - { - "symbol": "d2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - } - ] - }, - "5": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "==", - "sort": "Pos # Pos -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "1", - "sort": null, - "args": [] - } - ] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "@most_significant_digit", - "sort": "@word -> Pos", - "args": [ - { - "symbol": "2", - "sort": null, - "args": [] - } - ] - }, - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "d2", - "sort": "Bool", - "args": [] - } - ] - } - ] - }, - "6": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "false", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d2", - "sort": "Bool", - "args": [] - } - ] - }, - "7": { - "condition": { - "symbol": "true", - "sort": "Bool", - "args": [] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "c2", - "sort": "Pos", - "args": [] - }, - { - "symbol": "d2", - "sort": "Bool", - "args": [] - } - ] - } - }, - "src_tgt": { - "0": [ - 4 - ], - "2": [ - 5 - ] - }, - "copy": { - "0": [ - 5, - 6, - 7 - ], - "2": [ - 4, - 6, - 7 - ] - }, - "cliques": { - "0": "clique0", - "2": "clique1" - } -} \ No newline at end of file diff --git a/tools/mcrl2/pbes/src/snapshots/c.text.pbes.json b/tools/mcrl2/pbes/src/snapshots/c.text.pbes.json deleted file mode 100644 index 4f6ac1642..000000000 --- a/tools/mcrl2/pbes/src/snapshots/c.text.pbes.json +++ /dev/null @@ -1,705 +0,0 @@ -{ - "mapping": { - "0": "c1", - "1": "d1", - "2": "c2", - "3": "d2", - "4": "X[0]", - "5": "X[1]", - "6": "Y[0]", - "7": "Y[1]", - "8": "Y[2]", - "9": "Y[3]", - "10": "X_false[0]", - "11": "X_true[0]" - }, - "pars": [ - 0, - 1, - 2, - 3 - ], - "clauses": [ - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11 - ], - "cfp": [ - 0, - 2 - ], - "dp": [ - 1, - 3 - ], - "uf": { - "4": [], - "5": [], - "6": [], - "7": [], - "8": [ - 1, - 3 - ], - "9": [ - 1, - 3 - ], - "10": [], - "11": [] - }, - "ui": { - "4": [ - 3 - ], - "5": [ - 1 - ], - "6": [], - "7": [], - "8": [ - 1, - 3 - ], - "9": [ - 1, - 3 - ], - "10": [], - "11": [] - }, - "cb": { - "4": [], - "5": [], - "6": [ - 1 - ], - "7": [ - 3 - ], - "8": [], - "9": [], - "10": [], - "11": [] - }, - "expressions": { - "4": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "@cInt", - "sort": "Nat -> Int", - "args": [ - { - "symbol": "@most_significant_digitNat", - "sort": "@word -> Nat", - "args": [ - { - "symbol": "1", - "sort": null, - "args": [] - } - ] - } - ] - }, - { - "symbol": "c2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d2", - "sort": "Int", - "args": [] - } - ] - }, - "5": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d1", - "sort": "Int", - "args": [] - }, - { - "symbol": "c2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "@cInt", - "sort": "Nat -> Int", - "args": [ - { - "symbol": "@most_significant_digitNat", - "sort": "@word -> Nat", - "args": [ - { - "symbol": "1", - "sort": null, - "args": [] - } - ] - } - ] - } - ] - }, - "6": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "@cInt", - "sort": "Nat -> Int", - "args": [ - { - "symbol": "@most_significant_digitNat", - "sort": "@word -> Nat", - "args": [ - { - "symbol": "2", - "sort": null, - "args": [] - } - ] - } - ] - }, - { - "symbol": "c2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d2", - "sort": "Int", - "args": [] - } - ] - }, - "7": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d1", - "sort": "Int", - "args": [] - }, - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "@cInt", - "sort": "Nat -> Int", - "args": [ - { - "symbol": "@most_significant_digitNat", - "sort": "@word -> Nat", - "args": [ - { - "symbol": "2", - "sort": null, - "args": [] - } - ] - } - ] - } - ] - }, - "8": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": ">=", - "sort": "Int # Int -> Bool", - "args": [ - { - "symbol": "+", - "sort": "Int # Int -> Int", - "args": [ - { - "symbol": "d1", - "sort": "Int", - "args": [] - }, - { - "symbol": "d2", - "sort": "Int", - "args": [] - } - ] - }, - { - "symbol": "@cInt", - "sort": "Nat -> Int", - "args": [ - { - "symbol": "@most_significant_digitNat", - "sort": "@word -> Nat", - "args": [ - { - "symbol": "4", - "sort": null, - "args": [] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "-", - "sort": "Int # Int -> Int", - "args": [ - { - "symbol": "d1", - "sort": "Int", - "args": [] - }, - { - "symbol": "d2", - "sort": "Int", - "args": [] - } - ] - }, - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "-", - "sort": "Int # Int -> Int", - "args": [ - { - "symbol": "d2", - "sort": "Int", - "args": [] - }, - { - "symbol": "d1", - "sort": "Int", - "args": [] - } - ] - } - ] - }, - "9": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": ">=", - "sort": "Int # Int -> Bool", - "args": [ - { - "symbol": "+", - "sort": "Int # Int -> Int", - "args": [ - { - "symbol": "d2", - "sort": "Int", - "args": [] - }, - { - "symbol": "d1", - "sort": "Int", - "args": [] - } - ] - }, - { - "symbol": "@cInt", - "sort": "Nat -> Int", - "args": [ - { - "symbol": "@most_significant_digitNat", - "sort": "@word -> Nat", - "args": [ - { - "symbol": "4", - "sort": null, - "args": [] - } - ] - } - ] - } - ] - } - ] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "-", - "sort": "Int # Int -> Int", - "args": [ - { - "symbol": "d1", - "sort": "Int", - "args": [] - }, - { - "symbol": "d2", - "sort": "Int", - "args": [] - } - ] - }, - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "c2", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "-", - "sort": "Int # Int -> Int", - "args": [ - { - "symbol": "d2", - "sort": "Int", - "args": [] - }, - { - "symbol": "d1", - "sort": "Int", - "args": [] - } - ] - } - ] - }, - "10": { - "condition": { - "symbol": "&&", - "sort": "Bool # Bool -> Bool", - "args": [ - { - "symbol": "!", - "sort": "Bool -> Bool", - "args": [ - { - "symbol": "false", - "sort": "Bool", - "args": [] - } - ] - }, - { - "symbol": "true", - "sort": "Bool", - "args": [] - } - ] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d1", - "sort": "Int", - "args": [] - }, - { - "symbol": "c2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d2", - "sort": "Int", - "args": [] - } - ] - }, - "11": { - "condition": { - "symbol": "true", - "sort": "Bool", - "args": [] - }, - "updates": [ - { - "symbol": "c1", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d1", - "sort": "Int", - "args": [] - }, - { - "symbol": "c2", - "sort": "Bool", - "args": [] - }, - { - "symbol": "d2", - "sort": "Int", - "args": [] - } - ] - } - }, - "src_tgt": { - "0": [ - 4, - 6, - 8, - 9 - ], - "2": [ - 5, - 7, - 8, - 9 - ] - }, - "copy": { - "0": [ - 4, - 5, - 7, - 10, - 11 - ], - "2": [ - 4, - 5, - 6, - 10, - 11 - ] - }, - "cliques": { - "0": "clique0", - "2": "clique0" - } -} \ No newline at end of file