diff --git a/README.md b/README.md index 03b263a..c5a7321 100644 --- a/README.md +++ b/README.md @@ -11,13 +11,16 @@ The chase algorithm is a fundamental technique in context of database theory and - Ontology-based data access (OBDA) - Datalog with existential rules -This implementation provides a **restricted chase** that guarantees termination even with existential rules by tracking applied triggers. +This implementation provides a restricted-chase style materialization with active-trigger checks. +The default entrypoints use a `10_000` step safeguard and report incomplete results with +`terminated == false` if that limit is reached. ### Features - **Core Data Types**: Terms, Atoms, Rules, and Instances - **Existential Quantification**: Automatic generation of labeled nulls -- **Restricted Chase**: Termination guarantees via trigger tracking +- **Restricted Chase Style**: Active-trigger checks for existential rules +- **Configurable Step Limit**: `chase_with_config` exposes the step bound - **Fluent API**: `RuleBuilder` for readable rule construction - **Interactive Frontends**: REPL, script runner, and a local GUI - **Zero Dependencies**: Pure Rust with no external runtime dependencies @@ -65,6 +68,29 @@ assert!(result.terminated); println!("Derived {} facts", result.instance.len()); ``` +Use `chase_with_config` to change the default step bound: + +```rust +use chase_rs::{Atom, ChaseConfig, Instance, Term, chase_with_config}; +use chase_rs::chase::rule::RuleBuilder; + +let instance: Instance = vec![Atom::new("P", vec![Term::constant("a")])] + .into_iter() + .collect(); +let rule = RuleBuilder::new() + .when("P", vec![Term::var("X")]) + .then("Q", vec![Term::var("X")]) + .build(); + +let result = chase_with_config( + instance, + &[rule], + ChaseConfig { max_steps: 100 }, +); + +assert!(result.terminated); +``` + #### Existential Rules Rules with head-only variables (existential quantification) automatically generate fresh labeled nulls: diff --git a/src/chase/instance.rs b/src/chase/instance.rs index e45f645..69a154d 100644 --- a/src/chase/instance.rs +++ b/src/chase/instance.rs @@ -1,55 +1,99 @@ //! A database instance is a set of ground atoms (facts). -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::error::Error; use std::fmt; use super::atom::Atom; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InstanceError { + NonGroundFact(Atom), +} + +impl fmt::Display for InstanceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InstanceError::NonGroundFact(atom) => { + write!(f, "facts must be ground atoms: {}", atom) + } + } + } +} + +impl Error for InstanceError {} + /// A database instance containing ground atoms. #[derive(Debug, Clone, Default)] pub struct Instance { - facts: HashSet, + facts_by_predicate: HashMap>, + len: usize, } impl Instance { /// Create an empty instance. pub fn new() -> Self { Instance { - facts: HashSet::new(), + facts_by_predicate: HashMap::new(), + len: 0, } } + /// Try to add a fact to the instance. Returns true if the fact was new. + pub fn try_add(&mut self, fact: Atom) -> Result { + if !fact.is_ground() { + return Err(InstanceError::NonGroundFact(fact)); + } + + let bucket = self + .facts_by_predicate + .entry(fact.predicate.clone()) + .or_default(); + let inserted = bucket.insert(fact); + if inserted { + self.len += 1; + } + + Ok(inserted) + } + /// Add a fact to the instance. Returns true if the fact was new. + /// + /// Panics if the atom is not ground. pub fn add(&mut self, fact: Atom) -> bool { - debug_assert!(fact.is_ground(), "Facts must be ground atoms"); - self.facts.insert(fact) + self.try_add(fact).expect("facts must be ground atoms") } /// Check if the instance contains a fact. pub fn contains(&self, fact: &Atom) -> bool { - self.facts.contains(fact) + self.facts_by_predicate + .get(&fact.predicate) + .is_some_and(|facts| facts.contains(fact)) } /// Get the number of facts. pub fn len(&self) -> usize { - self.facts.len() + self.len } /// Check if the instance is empty. pub fn is_empty(&self) -> bool { - self.facts.is_empty() + self.len == 0 } /// Iterate over all facts. pub fn iter(&self) -> impl Iterator { - self.facts.iter() + self.facts_by_predicate + .values() + .flat_map(|facts| facts.iter()) } /// Get all facts with a given predicate. pub fn facts_for_predicate(&self, predicate: &str) -> Vec<&Atom> { - self.facts - .iter() - .filter(|a| a.predicate == predicate) + self.facts_by_predicate + .get(predicate) + .into_iter() + .flat_map(|facts| facts.iter()) .collect() } } @@ -57,7 +101,7 @@ impl Instance { impl fmt::Display for Instance { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { writeln!(f, "Instance {{")?; - for fact in &self.facts { + for fact in self.iter() { writeln!(f, " {}", fact)?; } write!(f, "}}") @@ -116,4 +160,15 @@ mod tests { assert_eq!(instance.facts_for_predicate("Person").len(), 1); assert_eq!(instance.facts_for_predicate("Other").len(), 0); } + + #[test] + fn test_try_add_rejects_non_ground_facts() { + let mut instance = Instance::new(); + let fact = Atom::new("Parent", vec![Term::var("X"), Term::constant("bob")]); + + let error = instance.try_add(fact).unwrap_err(); + + assert!(matches!(error, InstanceError::NonGroundFact(_))); + assert!(instance.is_empty()); + } } diff --git a/src/chase/mod.rs b/src/chase/mod.rs index 818f017..78a44b9 100644 --- a/src/chase/mod.rs +++ b/src/chase/mod.rs @@ -9,8 +9,8 @@ pub mod term; mod engine; pub use atom::Atom; -pub use engine::{ChaseResult, chase}; -pub use instance::Instance; +pub use engine::{ChaseConfig, ChaseResult, chase, chase_with_config}; +pub use instance::{Instance, InstanceError}; pub use rule::Rule; pub use substitution::Substitution; pub use term::Term; diff --git a/src/frontend/language.rs b/src/frontend/language.rs index e41cebd..efe76c2 100644 --- a/src/frontend/language.rs +++ b/src/frontend/language.rs @@ -53,6 +53,9 @@ pub fn parse_command(input: &str) -> Result { if let Some(rest) = strip_keyword(trimmed, "fact") { let atom = parse_atom(trim_suffix(rest, '.')?)?; + if !atom.is_ground() { + return Err("facts must be ground atoms".to_string()); + } return Ok(Command::Fact(atom)); } @@ -346,6 +349,12 @@ mod tests { } } + #[test] + fn parse_fact_command_rejects_variables() { + let error = parse_command("fact Parent(?X, bob).").unwrap_err(); + assert_eq!(error, "facts must be ground atoms"); + } + #[test] fn parse_rule_command() { let command = parse_command("rule P(?X), Q(?X, a) -> R(?X).").unwrap(); diff --git a/src/frontend/provenance.rs b/src/frontend/provenance.rs index 6bb4618..42642f2 100644 --- a/src/frontend/provenance.rs +++ b/src/frontend/provenance.rs @@ -3,9 +3,7 @@ use std::collections::{HashMap, HashSet}; use crate::chase::substitution::unify_atom; -use crate::{Atom, ChaseResult, Instance, Rule, Substitution, Term}; - -const DEFAULT_MAX_STEPS: usize = 10_000; +use crate::{Atom, ChaseConfig, ChaseResult, Instance, Rule, Substitution, Term}; #[derive(Debug)] pub struct MaterializedState { @@ -76,12 +74,13 @@ pub fn materialize(base_instance: Instance, rules: &[Rule]) -> MaterializedState .cloned() .map(|fact| (fact, Derivation::Input)) .collect::>(); + let max_steps = ChaseConfig::default().max_steps; let mut null_gen = NullGenerator::seeded_from(&instance, rules); let mut applied_triggers = HashSet::new(); let mut steps = 0; loop { - if steps >= DEFAULT_MAX_STEPS { + if steps >= max_steps { return MaterializedState { result: ChaseResult { instance, diff --git a/src/frontend/session.rs b/src/frontend/session.rs index 0aa887d..f3b3d2b 100644 --- a/src/frontend/session.rs +++ b/src/frontend/session.rs @@ -41,7 +41,10 @@ impl Session { match command { Command::Fact(atom) => { self.materialized = None; - let inserted = self.base_instance.add(atom.clone()); + let inserted = self + .base_instance + .try_add(atom.clone()) + .map_err(|err| err.to_string())?; let action = if inserted { "Added" } else { diff --git a/src/lib.rs b/src/lib.rs index 6e5d103..e2b217e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,4 +2,7 @@ pub mod chase; pub mod frontend; // Re-export main types for convenience -pub use chase::{Atom, ChaseResult, Instance, Rule, Substitution, Term, chase}; +pub use chase::{ + Atom, ChaseConfig, ChaseResult, Instance, InstanceError, Rule, Substitution, Term, chase, + chase_with_config, +};