Skip to content

Commit

Permalink
tree_morph: implement rule priority groups
Browse files Browse the repository at this point in the history
the main engine has been updated to make use of Uniplate's wonderful "contexts" method to abstract over and optimise the actual tree traversal.

bump uniplate version

update documentation
  • Loading branch information
lixitrixi committed Jan 16, 2025
1 parent c4c4d7d commit 2a8efa9
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 2a8efa9

Please sign in to comment.