rssn-advanced 0.1.0

This is rssn-advanced: The next generation symbolic core of rssn.
Documentation
//! Configurable heuristic pattern-matching engine.
//!
//! Rewritten per `heuristic_review §1` / `summary_review §3.4`:
//!
//! 1. Operates on `DagBuilder` (not raw `DagArena`) so every rewrite
//!    goes through the dedup map — no more "symbol explosion" from
//!    duplicate-node allocation.
//! 2. Tree walk is iterative — no recursion → no stack overflow on
//!    industrial-scale expressions.
//! 3. Applies real pattern matching from [`super::patterns`] before
//!    falling back to a "rebuild from rewritten children" loop.

use std::collections::HashSet;
use std::sync::Arc;
use std::time::Instant;

use super::knobs::HeuristicConfig;
use super::patterns;
use super::rule_registry::RuleRegistry;
use super::simplifier::approximate_simplify;
use super::strategy::SearchStrategy;
use crate::dag::builder::DagBuilder;
use crate::dag::node::DagNodeId;
use crate::dag::symbol::SymbolKind;
use crate::egraph::EGraph;

/// Each frame remembers a cursor over its source children. Once `cursor
/// == arity` the rewritten children are popped off the value stack and
/// passed to [`rebuild_or_match`].
struct Frame {
    node_id: DagNodeId,
    depth: usize,
    cursor: usize,
    arity: usize,
    kind: SymbolKind,
}

/// A rule-based pattern matching and algebraic simplification engine.
#[derive(Debug, Clone)]
pub struct HeuristicEngine {
    /// Budget parameters (depth, branching limit, timeouts).
    pub config: HeuristicConfig,
    /// Search path strategy.
    pub strategy: SearchStrategy,
    /// Canonical cache: nodes already fully simplified in previous calls.
    /// Persists across `simplify` calls to avoid re-processing shared subtrees.
    canonical_cache: HashSet<DagNodeId>,
    /// User-supplied rewrite rules, tried before the built-in patterns.
    rule_registry: Arc<RuleRegistry>,
}

impl HeuristicEngine {
    /// Creates a new `HeuristicEngine` with the given knobs and strategy.
    #[must_use]
    pub fn new(config: HeuristicConfig, strategy: SearchStrategy) -> Self {
        Self {
            config,
            strategy,
            canonical_cache: HashSet::new(),
            rule_registry: Arc::new(RuleRegistry::new()),
        }
    }

    /// Returns a new `HeuristicEngine` that uses the given rule registry.
    ///
    /// Rules in the registry are tried before the engine's built-in patterns.
    #[must_use]
    pub fn with_rule_registry(mut self, registry: Arc<RuleRegistry>) -> Self {
        self.rule_registry = registry;
        self
    }

    /// Simplifies the target expression iteratively, respecting the
    /// configured depth, branching, and timeout knobs.
    ///
    /// Every replacement node flows through `DagBuilder`, so structural
    /// deduplication is preserved across the entire rewrite.
    ///
    /// The persistent `canonical_cache` accumulates across calls so that
    /// shared subtrees are never re-processed in subsequent invocations.
    #[must_use]
    pub fn simplify(&mut self, builder: &mut DagBuilder, root: DagNodeId) -> DagNodeId {
        let start_time = Instant::now();

        // 1. Optional approximate pre-pruning for high-aggressiveness configs.
        let pre = if self.config.simplification_aggressiveness > 0.5 {
            approximate_simplify(builder, root, self.config.simplification_aggressiveness)
        } else {
            root
        };

        // 2. Iterative pattern-matched rewrite.
        let after_heuristic = self.rewrite_iterative(builder, pre, start_time);

        // 3. Optional E-graph equality-saturation post-pass.
        if self.config.use_egraph {
            self.run_egraph_pass(builder, after_heuristic)
        } else {
            after_heuristic
        }
    }

    /// Runs the E-graph equality saturation + extraction pass.
    fn run_egraph_pass(&self, builder: &mut DagBuilder, root: DagNodeId) -> DagNodeId {
        let mut eg = EGraph::new(builder, self.config.egraph_config);
        eg.saturate(root);
        eg.extract(root)
    }

    fn rewrite_iterative(
        &mut self,
        builder: &mut DagBuilder,
        root: DagNodeId,
        start_time: Instant,
    ) -> DagNodeId {
        if root.is_none() {
            return root;
        }
        if start_time.elapsed() >= self.config.timeout {
            return root;
        }

        let mut stack: Vec<Frame> = Vec::with_capacity(64);
        let mut values: Vec<DagNodeId> = Vec::with_capacity(64);
        // `self.canonical_cache` persists across calls: nodes already proven
        // fully simplified are never re-walked, even across separate `simplify`
        // invocations on the same engine instance.

        // If the root doesn't resolve we treat the call as a no-op
        // instead of panicking (`storage_review §4` / Phase 7).
        let Some(root_node) = builder.arena().get(root) else {
            return root;
        };
        stack.push(Frame {
            node_id: root,
            depth: 0,
            cursor: 0,
            arity: root_node.children.len(),
            kind: root_node.kind,
        });

        while let Some(top) = stack.last_mut() {
            // Budget enforcement: depth + timeout.
            if top.depth >= self.config.max_depth || start_time.elapsed() >= self.config.timeout {
                let Some(frame) = stack.pop() else { break };
                values.truncate(values.len().saturating_sub(frame.cursor));
                values.push(frame.node_id);
                continue;
            }

            // Children to process? If yes, descend into the next one.
            if top.cursor < top.arity {
                let parent_id = top.node_id;
                let cursor = top.cursor;
                top.cursor += 1;
                let parent_depth = top.depth;

                let child_id = builder
                    .arena()
                    .get(parent_id)
                    .and_then(|n| n.children.as_slice().get(cursor).copied())
                    .unwrap_or(DagNodeId::NONE);

                if child_id.is_none() {
                    values.push(DagNodeId::NONE);
                    continue;
                }

                // Defensive: corrupt child reference becomes a leaf-id
                // pushed onto the value stack instead of a panic.
                let Some(child_node) = builder.arena().get(child_id) else {
                    values.push(child_id);
                    continue;
                };
                let child_kind = child_node.kind;
                let child_arity = child_node.children.len();

                if child_arity == 0 {
                    values.push(child_id);
                    continue;
                }

                // Skip already-simplified subtrees: if the child was marked
                // canonical in a previous or current simplify call, recurse is
                // unnecessary — push it directly onto the value stack.
                let is_canonical = self.canonical_cache.contains(&child_id)
                    || builder
                        .arena()
                        .get(child_id)
                        .is_some_and(|n| n.meta.flags.is_canonical());
                if is_canonical {
                    values.push(child_id);
                    continue;
                }

                stack.push(Frame {
                    node_id: child_id,
                    depth: parent_depth + 1,
                    cursor: 0,
                    arity: child_arity,
                    kind: child_kind,
                });
            } else {
                let Some(frame) = stack.pop() else { break };
                let split_at = values.len().saturating_sub(frame.arity);
                // Borrow the children as a slice instead of collecting into
                // a Vec — avoids a heap allocation per node. NLL guarantees
                // the immutable borrow ends before `truncate`.
                let rebuilt = rebuild_or_match(
                    &self.rule_registry,
                    builder,
                    frame.kind,
                    &values[split_at..],
                    frame.node_id,
                );
                values.truncate(split_at);
                self.canonical_cache.insert(rebuilt);
                // Persist the canonical state in the arena node itself so other
                // engine instances (and cross-call) skip re-simplification.
                if let Some(node) = builder.arena_mut().get_mut(rebuilt) {
                    node.meta.flags = node.meta.flags.with_canonical();
                }
                values.push(rebuilt);
            }
        }

        values.pop().unwrap_or(root)
    }
}

/// Rebuilds a node from rewritten children, applying user-registered rules
/// first, then pattern rules from [`super::patterns`], before falling back
/// to `DagBuilder` constructors.
///
/// Returns `original` unchanged if the rewritten children are
/// identical to the source children (the no-op fast path).
fn rebuild_or_match(
    registry: &RuleRegistry,
    builder: &mut DagBuilder,
    kind: SymbolKind,
    new_children: &[DagNodeId],
    original: DagNodeId,
) -> DagNodeId {
    // User-registered rules take highest priority.
    if let Some(replacement) = registry.try_apply(builder, kind, new_children) {
        return replacement;
    }

    // Built-in patterns — `x + 0`, `x * 1`, etc. — fire next.
    if let Some(replacement) = patterns::try_apply(builder, kind, new_children) {
        return replacement;
    }

    // Fast path: if no pattern fired and the rewritten children equal
    // the source, keep the original id so DAG sharing is preserved.
    let original_same = builder
        .arena()
        .get(original)
        .is_some_and(|n| n.children.as_slice() == new_children);
    if original_same {
        return original;
    }

    // Plain rebuild via the appropriate builder constructor.
    match kind {
        SymbolKind::Operator(crate::dag::symbol::OpKind::Add) if new_children.len() == 2 => {
            builder.add(new_children[0], new_children[1])
        }
        SymbolKind::Operator(crate::dag::symbol::OpKind::Sub) if new_children.len() == 2 => {
            builder.sub(new_children[0], new_children[1])
        }
        SymbolKind::Operator(crate::dag::symbol::OpKind::Mul) if new_children.len() == 2 => {
            builder.mul(new_children[0], new_children[1])
        }
        SymbolKind::Operator(crate::dag::symbol::OpKind::Div) if new_children.len() == 2 => {
            builder.div(new_children[0], new_children[1])
        }
        SymbolKind::Operator(crate::dag::symbol::OpKind::Pow) if new_children.len() == 2 => {
            builder.pow(new_children[0], new_children[1])
        }
        SymbolKind::Operator(crate::dag::symbol::OpKind::Mod) if new_children.len() == 2 => {
            builder.modulo(new_children[0], new_children[1])
        }
        SymbolKind::Operator(crate::dag::symbol::OpKind::Neg) if new_children.len() == 1 => {
            builder.neg(new_children[0])
        }
        _ => builder.operator(kind, new_children, crate::dag::metadata::NodeFlags::EMPTY),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::dag::symbol::{OpKind, SymbolKind};

    #[test]
    fn simplify_preserves_dedup_on_deep_chain() {
        // (((x+x)+x)+x)+...+x — 2000 levels deep. The pre-rewrite
        // arena has 2001 nodes (one variable + 2000 add ops). After
        // simplification with a no-op config the arena MUST NOT grow
        // (any growth means dedup got bypassed).
        let mut b = DagBuilder::new();
        let x = b.variable("x");
        let mut acc = x;
        for _ in 0..2000 {
            acc = b.add(acc, x);
        }
        let pre_size = b.arena().len();

        let mut engine = HeuristicEngine::new(HeuristicConfig::default(), SearchStrategy::Greedy);
        let out = engine.simplify(&mut b, acc);

        let post_size = b.arena().len();
        // The engine may have produced new nodes if rewrites fired,
        // but it must never produce duplicates of structurally
        // identical existing nodes. We check this by re-building the
        // same expression and confirming the count doesn't double.
        let baseline_after_rebuild = post_size;
        let mut acc2 = x;
        for _ in 0..2000 {
            acc2 = b.add(acc2, x);
        }
        assert_eq!(
            b.arena().len(),
            baseline_after_rebuild,
            "rebuilding the same expression must not allocate any new nodes \
             — dedup is broken"
        );
        // Outputs are well-defined (non-NONE) DagNodeIds.
        assert!(!out.is_none());
        let _ = pre_size;
    }

    #[test]
    fn simplify_folds_x_plus_zero() {
        let mut b = DagBuilder::new();
        let x = b.variable("x");
        let zero = b.constant(0.0);
        let expr = b.add(x, zero);

        let mut engine = HeuristicEngine::new(HeuristicConfig::default(), SearchStrategy::Greedy);
        let result = engine.simplify(&mut b, expr);

        // After folding `x + 0 → x`, the result must be `x` itself.
        assert_eq!(result, x);
    }

    #[test]
    fn simplify_handles_nested_identities() {
        let mut b = DagBuilder::new();
        let x = b.variable("x");
        let zero = b.constant(0.0);
        let one = b.constant(1.0);
        // ((x + 0) * 1)
        let inner = b.add(x, zero);
        let outer = b.mul(inner, one);

        let mut engine = HeuristicEngine::new(HeuristicConfig::default(), SearchStrategy::Greedy);
        let result = engine.simplify(&mut b, outer);
        assert_eq!(
            result, x,
            "nested identity rewrites should collapse to the variable"
        );
        // Outputs reduce a 2-op tree to a leaf — kind check.
        let node = b.arena().get(result).expect("result");
        assert!(matches!(node.kind, SymbolKind::Variable(_)));
        let _ = OpKind::Add;
    }
}