This commit is contained in:
Hassan Abedi 2026-03-13 10:20:52 +01:00
parent 02e2e45848
commit fc66b8e393
7 changed files with 118 additions and 23 deletions

View File

@ -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:

View File

@ -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<Atom>,
facts_by_predicate: HashMap<String, HashSet<Atom>>,
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<bool, InstanceError> {
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<Item = &Atom> {
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());
}
}

View File

@ -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;

View File

@ -53,6 +53,9 @@ pub fn parse_command(input: &str) -> Result<Command, String> {
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();

View File

@ -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::<HashMap<_, _>>();
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,

View File

@ -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 {

View File

@ -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,
};