Skip to content

Commit

Permalink
Merge pull request #586 from lixitrixi/rule-groups
Browse files Browse the repository at this point in the history
Implement rule priority groups to tree_morph
  • Loading branch information
ozgurakgun authored Jan 17, 2025
2 parents c4c4d7d + 2a8efa9 commit fb9434b
Show file tree
Hide file tree
Showing 6 changed files with 153 additions and 90 deletions.
2 changes: 1 addition & 1 deletion crates/tree_morph/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ edition = "2021"
[dependencies]
multipeek = "0.1.2"
rand = "0.8.5"
uniplate = { version = "0.1.0" }
uniplate = "0.1.5"


[lints]
Expand Down
2 changes: 1 addition & 1 deletion crates/tree_morph/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,6 @@ mod reduction;
mod rule;

pub use commands::Commands;
pub use reduce::{reduce, reduce_with_rules};
pub use reduce::{reduce, reduce_with_rule_groups, reduce_with_rules};
pub use reduction::Reduction;
pub use rule::Rule;
133 changes: 54 additions & 79 deletions crates/tree_morph/src/reduce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,107 +10,82 @@ use uniplate::Uniplate;
// TODO: (Felix) add "control" rules; e.g. ignore a subtree to a certain depth?
// test by ignoring everything once a metadata field is set? e.g. "reduce until contains X"

/// Exhaustively reduce a tree using a given transformation function.
/// Exhaustively transform a tree with the given list of functions.
///
/// The transformation function is called on each node in the tree (in left-most, outer-most order) along with
/// the metadata and a `Commands` object for side-effects.
/// Each transform function is applied to every node before the next function is tried.
/// When any change is made, the tree is updated and side-effects are applied before the process
/// restarts with the first transform function.
///
/// - When the transformation function returns `Some(new_node)` for some node, that node is replaced with `new_node`.
/// Any side-effects are then applied at the root of the tree and the traversal begins again.
/// - Once the transformation function returns `None` for all nodes, the reduction is complete.
///
/// The `Commands` object is used to apply side-effects after a transformation is made.
/// This can be used to update metadata or perform arbitrary transformations on the entire tree,
/// which are reflected in the next traversal iteration.
///
/// # Parameters
/// - `transform`: A function which takes a mutable reference to a `Commands` object, a reference to the current node, and a reference to the metadata.
/// The function should return `Some(new_node)` if the node was transformed, or `None` otherwise.
/// - `tree`: The tree to reduce.
/// - `meta`: Metadata to be passed to the transformation function. This persists across all transformations.
///
/// # Returns
/// A tuple containing the reduced tree and the final metadata.
pub fn reduce<T, M, F>(transform: F, mut tree: T, mut meta: M) -> (T, M)
/// Once the last transform function makes no changes, this function returns the updated tree and metadata.
pub fn reduce<T, M, F>(transforms: &[F], mut tree: T, mut meta: M) -> (T, M)
where
T: Uniplate,
F: Fn(&mut Commands<T, M>, &T, &M) -> Option<T>,
{
// Apply the transformation to the tree until no more changes are made
while let Some(mut reduction) = reduce_iteration(&transform, &tree, &meta) {
// Apply reduction side-effects
(tree, meta) = reduction.commands.apply(reduction.new_tree, meta);
let mut new_tree = tree;
'main: loop {
tree = new_tree;
for transform in transforms.iter() {
// Try each transform on the entire tree before moving to the next
for (node, ctx) in tree.contexts() {
let red_opt = Reduction::apply_transform(transform, &node, &meta);

if let Some(mut red) = red_opt {
(new_tree, meta) = red.commands.apply(ctx(red.new_tree), meta);

// Restart with the first transform every time a change is made
continue 'main;
}
}
}
// All transforms were attempted without change
break;
}
(tree, meta)
}

fn reduce_iteration<T, M, F>(transform: &F, subtree: &T, meta: &M) -> Option<Reduction<T, M>>
/// Exhaustively transform a tree with the given list of rules.
///
/// If multiple rules apply to a node, the `select` function is used to choose which one to apply.
///
/// This is a special case of [`reduce_with_rule_groups`] with a single rule group.
pub fn reduce_with_rules<T, M, R, S>(rules: &[R], select: S, tree: T, meta: M) -> (T, M)
where
T: Uniplate,
F: Fn(&mut Commands<T, M>, &T, &M) -> Option<T>,
R: Rule<T, M>,
S: Fn(&T, &mut dyn Iterator<Item = (&R, Reduction<T, M>)>) -> Option<Reduction<T, M>>,
{
// Try to apply the transformation to the current node
let reduction = Reduction::apply_transform(transform, subtree, meta);
if reduction.is_some() {
return reduction;
}

// Try to call the transformation on the children of the current node
// If successful, return the new subtree
let mut children = subtree.children();
for c in children.iter_mut() {
if let Some(reduction) = reduce_iteration(transform, c, meta) {
*c = reduction.new_tree;
return Some(Reduction {
new_tree: subtree.with_children(children),
..reduction
});
}
}

None
reduce_with_rule_groups(&[rules], select, tree, meta)
}

/// Exhaustively reduce a tree by applying the given rules at each node.
///
/// Rules are applied in the order they are given. If multiple rules can be applied to a node,
/// the `select` function is used to choose which rule to apply.
/// Exhaustively transform a tree with the given list of rule groups.
/// A 'rule group' represents a higher-priority set of rules which are applied to the entire tree before subsequent groups.
///
/// `Reduction`s encapsulate the result of applying a rule at a given node, holding the resulting node
/// and any side-effects. An iterator of these objects (along with the rule they result from)
/// is given to the `select` function, and the one returned is applied to the tree as in the `reduce` function.
/// If multiple rules apply to a node, the `select` function is used to choose which one to apply.
///
/// # Parameters
/// - `rules`: A slice of rules to apply to the tree.
/// - `select`: A function which takes the current node and an iterator of rule-`Reduction` pairs and returns the selected `Reduction`.
/// - `tree`: The tree to reduce.
/// - `meta`: Metadata to be passed to the transformation function. This persists across all transformations.
///
/// # Returns
/// A tuple containing the reduced tree and the final metadata.
pub fn reduce_with_rules<T, M, R, S>(rules: &[R], select: S, tree: T, meta: M) -> (T, M)
/// This is an abstraction over [`reduce`], where each transform function attempts a rule group on each node.
pub fn reduce_with_rule_groups<T, M, R, S>(groups: &[&[R]], select: S, tree: T, meta: M) -> (T, M)
where
T: Uniplate,
R: Rule<T, M>,
S: Fn(&T, &mut dyn Iterator<Item = (&R, Reduction<T, M>)>) -> Option<Reduction<T, M>>,
{
reduce(
|commands, subtree, meta| {
let selection = one_or_select(
&select,
subtree,
&mut rules.iter().filter_map(|rule| {
let transforms: Vec<_> = groups
.iter()
.map(|group| {
|commands: &mut Commands<T, M>, subtree: &T, meta: &M| {
let applicable = &mut group.iter().filter_map(|rule| {
Reduction::apply_transform(|c, t, m| rule.apply(c, t, m), subtree, meta)
.map(|r| (rule, r))
}),
);
selection.map(|r| {
// Ensure commands used by the engine are the ones resulting from this reduction
*commands = r.commands;
r.new_tree
})
},
tree,
meta,
)
});
let selection = one_or_select(&select, subtree, applicable);
selection.map(|r| {
// Ensure commands used by the engine are the ones resulting from this reduction
*commands = r.commands;
r.new_tree
})
}
})
.collect();
reduce(&transforms, tree, meta)
}
7 changes: 5 additions & 2 deletions crates/tree_morph/tests/depend_meta.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
//! Here we test an interesting side-effect case, with rules which return a reduction based on a metadata field.
//! These rules will not be run a second time if no other rule applies to the same node, which might be unexpected.
// TODO (Felix) how might we fix this, in the engine or by blocking this use case?

use tree_morph::*;
use uniplate::derive::Uniplate;

Expand All @@ -22,10 +24,11 @@ fn transform(cmd: &mut Commands<Expr, bool>, expr: &Expr, meta: &bool) -> Option
None
}

// #[test] // TODO (Felix) how might we fix this, in the engine or by blocking this use case?
#[test]
#[ignore = "this will fail until we fix it"]
fn test_meta_branching_side_effect() {
let expr = Expr::One;
let (expr, meta) = reduce(transform, expr, false);
let (expr, meta) = reduce(&[transform], expr, false);
assert_eq!(expr, Expr::Two);
assert_eq!(meta, true);
}
14 changes: 7 additions & 7 deletions crates/tree_morph/tests/lambda_calc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ fn test_simple_application() {
Box::new(Expr::Abs(0, Box::new(Expr::Var(0)))),
Box::new(Expr::Var(1)),
);
let (result, meta) = reduce(transform_beta_reduce, expr, 0);
let (result, meta) = reduce(&[transform_beta_reduce], expr, 0);
assert_eq!(result, Expr::Var(1));
assert_eq!(meta, 1);
}
Expand All @@ -129,7 +129,7 @@ fn test_nested_application() {
)),
Box::new(Expr::Var(2)),
);
let (result, meta) = reduce(transform_beta_reduce, expr, 0);
let (result, meta) = reduce(&[transform_beta_reduce], expr, 0);
assert_eq!(result, Expr::Var(2));
assert_eq!(meta, 2);
}
Expand All @@ -141,7 +141,7 @@ fn test_capture_avoiding_substitution() {
Box::new(Expr::Abs(0, Box::new(Expr::Abs(1, Box::new(Expr::Var(0)))))),
Box::new(Expr::Var(1)),
);
let (result, meta) = reduce(transform_beta_reduce, expr, 0);
let (result, meta) = reduce(&[transform_beta_reduce], expr, 0);
assert_eq!(result, Expr::Abs(2, Box::new(Expr::Var(1))));
assert_eq!(meta, 1);
}
Expand All @@ -153,7 +153,7 @@ fn test_double_reduction() {
Box::new(Expr::Abs(0, Box::new(Expr::Abs(1, Box::new(Expr::Var(1)))))),
Box::new(Expr::Var(2)),
);
let (result, meta) = reduce(transform_beta_reduce, expr, 0);
let (result, meta) = reduce(&[transform_beta_reduce], expr, 0);
assert_eq!(result, Expr::Abs(1, Box::new(Expr::Var(1))));
assert_eq!(meta, 1);
}
Expand All @@ -162,7 +162,7 @@ fn test_double_reduction() {
fn test_id() {
// (\x. x) -> (\x. x)
let expr = Expr::Abs(0, Box::new(Expr::Var(0)));
let (expr, meta) = reduce(transform_beta_reduce, expr, 0);
let (expr, meta) = reduce(&[transform_beta_reduce], expr, 0);
assert_eq!(expr, Expr::Abs(0, Box::new(Expr::Var(0))));
assert_eq!(meta, 0);
}
Expand All @@ -171,7 +171,7 @@ fn test_id() {
fn test_no_reduction() {
// x -> x
let expr = Expr::Var(1);
let (result, meta) = reduce(transform_beta_reduce, expr.clone(), 0);
let (result, meta) = reduce(&[transform_beta_reduce], expr.clone(), 0);
assert_eq!(result, expr);
assert_eq!(meta, 0);
}
Expand All @@ -192,7 +192,7 @@ fn test_complex_expression() {
)),
Box::new(Expr::Abs(3, Box::new(Expr::Var(3)))),
);
let (result, meta) = reduce(transform_beta_reduce, expr, 0);
let (result, meta) = reduce(&[transform_beta_reduce], expr, 0);
assert_eq!(result, Expr::Abs(3, Box::new(Expr::Var(3))));
assert_eq!(meta, 3);
}
85 changes: 85 additions & 0 deletions crates/tree_morph/tests/rule_groups.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//! Here we test the `reduce_with_rule_groups` function.
//! Each rule group is applied to the whole tree as with `reduce_with_rules`, before the next group is tried.
//! Every time a change is made, the algorithm starts again with the first group.
//!
//! This lets us make powerful "evaluation" rules which greedily reduce the tree as much as possible, before other
//! "rewriting" rules are applied.
use tree_morph::{helpers::select_first, *};
use uniplate::derive::Uniplate;

/// A simple language of two literals and a wrapper
#[derive(Debug, Clone, PartialEq, Eq, Uniplate)]
#[uniplate()]
enum Expr {
A, // a
B, // b
Wrap(Box<Expr>), // [E]
}

/// Rule container: holds a primitive function and implements the Rule trait
struct Rl(fn(&Expr) -> Option<Expr>);

impl Rule<Expr, ()> for Rl {
fn apply(&self, cmd: &mut Commands<Expr, ()>, expr: &Expr, _: &()) -> Option<Expr> {
self.0(expr)
}
}

mod rules {
use super::*;

/// [a] ~> a
pub fn unwrap_a(expr: &Expr) -> Option<Expr> {
if let Expr::Wrap(inner) = expr {
if let Expr::A = **inner {
return Some(Expr::A);
}
}
None
}

/// a ~> b
pub fn a_to_b(expr: &Expr) -> Option<Expr> {
if let Expr::A = expr {
return Some(Expr::B);
}
None
}
}

#[test]
fn test_same_group() {
// If the rules are in the same group, unwrap_a will apply higher in the tree

// [a]
let expr = Expr::Wrap(Box::new(Expr::A));

let (expr, _) = reduce_with_rule_groups(
&[&[Rl(rules::unwrap_a), Rl(rules::a_to_b)]],
select_first,
expr,
(),
);

// [a] ~> a ~> b
assert_eq!(expr, Expr::B);
}

#[test]
fn test_a_to_b_first() {
// a_to_b is in a higher group than unwrap_a, so it will be applied first to the lower expression

// [a]
let expr = Expr::Wrap(Box::new(Expr::A));

let (expr, _) = reduce_with_rule_groups(
&[&[Rl(rules::a_to_b)], &[Rl(rules::unwrap_a)]],
select_first,
expr,
(),
);

// [a] ~> [b]
assert_eq!(expr, Expr::Wrap(Box::new(Expr::B)));
}

0 comments on commit fb9434b

Please sign in to comment.