Skip to content

Commit ec040ed

Browse files
authored
feat(phase-7.1a): stratified negation (not / not-join)
feat(phase-7.1a): stratified negation (not / not-join)
2 parents 981d868 + 58791fa commit ec040ed

9 files changed

Lines changed: 1906 additions & 126 deletions

File tree

src/query/datalog/evaluator.rs

Lines changed: 586 additions & 60 deletions
Large diffs are not rendered by default.

src/query/datalog/executor.rs

Lines changed: 284 additions & 29 deletions
Large diffs are not rendered by default.

src/query/datalog/matcher.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ impl PatternMatcher {
7171
bindings: &mut Bindings,
7272
) -> bool {
7373
match pattern_component {
74+
// Wildcard variable (starts with ?_): match any value without binding
75+
EdnValue::Symbol(var) if var.starts_with("?_") => true,
76+
7477
// Variable: bind it or check consistency
7578
EdnValue::Symbol(var) if var.starts_with('?') => {
7679
if let Some(existing) = bindings.get(var) {

src/query/datalog/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ pub mod matcher;
44
pub mod optimizer;
55
pub mod parser;
66
pub mod rules;
7+
pub mod stratification;
78
pub mod types;
89

910
pub use evaluator::RecursiveEvaluator;
11+
pub use evaluator::StratifiedEvaluator;
1012
pub use executor::{DatalogExecutor, QueryResult};
1113
pub use matcher::{Bindings, PatternMatcher, edn_to_entity_id, edn_to_value};
1214
pub use parser::{parse_datalog_command, parse_edn};

src/query/datalog/parser.rs

Lines changed: 268 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -489,24 +489,8 @@ fn parse_query(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
489489
let pattern = Pattern::from_edn(pattern_vec)?;
490490
where_clauses.push(WhereClause::Pattern(pattern));
491491
} else if let Some(rule_list) = query_vector[i].as_list() {
492-
// This is a rule invocation: (predicate ?arg1 ?arg2)
493-
if rule_list.is_empty() {
494-
return Err("Rule invocation cannot be empty".to_string());
495-
}
496-
497-
// First element should be the predicate name (symbol)
498-
let predicate = match &rule_list[0] {
499-
EdnValue::Symbol(s) => s.clone(),
500-
_ => {
501-
return Err("Rule invocation must start with predicate name (symbol)"
502-
.to_string());
503-
}
504-
};
505-
506-
// Rest are arguments
507-
let args = rule_list[1..].to_vec();
508-
509-
where_clauses.push(WhereClause::RuleInvocation { predicate, args });
492+
let clause = parse_list_as_where_clause(rule_list, true)?;
493+
where_clauses.push(clause);
510494
} else {
511495
return Err(format!(
512496
"Expected pattern vector or rule invocation in :where clause, got {:?}",
@@ -525,6 +509,13 @@ fn parse_query(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
525509
i += 1;
526510
}
527511

512+
// Safety check: all variables in (not ...) must be bound by outer clauses
513+
let outer_bound: std::collections::HashSet<String> = where_clauses
514+
.iter()
515+
.flat_map(outer_vars_from_clause)
516+
.collect();
517+
check_not_safety(&where_clauses, &outer_bound)?;
518+
528519
let mut query = DatalogQuery::new(find_vars, where_clauses);
529520
query.as_of = query_as_of;
530521
query.valid_at = query_valid_at;
@@ -672,6 +663,118 @@ fn parse_retract(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
672663
Ok(DatalogCommand::Retract(Transaction::new(patterns)))
673664
}
674665

666+
/// Parse a list item (EDN List) appearing in a :where clause or rule body.
667+
/// Returns Err if the list is empty, has an unknown form, or contains nested `not`.
668+
fn parse_list_as_where_clause(
669+
list: &[EdnValue],
670+
allow_not: bool,
671+
) -> Result<WhereClause, String> {
672+
if list.is_empty() {
673+
return Err("Empty list in :where clause".to_string());
674+
}
675+
match &list[0] {
676+
EdnValue::Symbol(s) if s == "not" => {
677+
if !allow_not {
678+
return Err(
679+
"(not ...) cannot appear inside another (not ...)".to_string(),
680+
);
681+
}
682+
if list.len() < 2 {
683+
return Err("(not) requires at least one clause".to_string());
684+
}
685+
let mut inner = Vec::new();
686+
for item in &list[1..] {
687+
if let Some(vec) = item.as_vector() {
688+
let pattern = Pattern::from_edn(vec)?;
689+
inner.push(WhereClause::Pattern(pattern));
690+
} else if let Some(inner_list) = item.as_list() {
691+
// Recurse with allow_not=false to reject nested not
692+
let clause = parse_list_as_where_clause(inner_list, false)?;
693+
inner.push(clause);
694+
} else {
695+
return Err(format!(
696+
"expected pattern or rule invocation inside (not), got {:?}",
697+
item
698+
));
699+
}
700+
}
701+
Ok(WhereClause::Not(inner))
702+
}
703+
EdnValue::Symbol(predicate) => {
704+
let args = list[1..].to_vec();
705+
Ok(WhereClause::RuleInvocation {
706+
predicate: predicate.clone(),
707+
args,
708+
})
709+
}
710+
_ => Err(format!(
711+
"Rule invocation must start with predicate name (symbol), got {:?}",
712+
list[0]
713+
)),
714+
}
715+
}
716+
717+
/// Collect all variable names that appear in a where clause (non-recursively into Not).
718+
fn outer_vars_from_clause(clause: &WhereClause) -> Vec<String> {
719+
match clause {
720+
WhereClause::Pattern(p) => {
721+
let mut vars = Vec::new();
722+
for v in [&p.entity, &p.attribute, &p.value] {
723+
if let Some(name) = v.as_variable()
724+
&& !name.starts_with("?_")
725+
{
726+
vars.push(name.to_string());
727+
}
728+
}
729+
vars
730+
}
731+
WhereClause::RuleInvocation { args, .. } => args
732+
.iter()
733+
.filter_map(|a| {
734+
a.as_variable().and_then(|s| {
735+
if !s.starts_with("?_") {
736+
Some(s.to_string())
737+
} else {
738+
None
739+
}
740+
})
741+
})
742+
.collect(),
743+
WhereClause::Not(_) => vec![], // not counted as "outer"
744+
}
745+
}
746+
747+
/// Collect all variable names that appear inside a Not clause.
748+
fn vars_in_not(clause: &WhereClause) -> Vec<String> {
749+
match clause {
750+
WhereClause::Not(inner) => inner
751+
.iter()
752+
.flat_map(outer_vars_from_clause)
753+
.collect(),
754+
_ => vec![],
755+
}
756+
}
757+
758+
/// Validate safety: every variable in a (not ...) body must be bound by an outer clause.
759+
fn check_not_safety(
760+
clauses: &[WhereClause],
761+
outer_bound: &std::collections::HashSet<String>,
762+
) -> Result<(), String> {
763+
for clause in clauses {
764+
if let WhereClause::Not(_) = clause {
765+
for var in vars_in_not(clause) {
766+
if !outer_bound.contains(&var) {
767+
return Err(format!(
768+
"variable {} in (not ...) is not bound by any outer clause",
769+
var
770+
));
771+
}
772+
}
773+
}
774+
}
775+
Ok(())
776+
}
777+
675778
fn parse_rule(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
676779
// Rule syntax: (rule [(predicate ?args) [pattern1] [pattern2] ...])
677780
// elements[0] = Vector with head (list) + body (patterns/rule calls)
@@ -704,13 +807,43 @@ fn parse_rule(elements: &[EdnValue]) -> Result<DatalogCommand, String> {
704807
_ => return Err("Rule head must start with a symbol (predicate name)".to_string()),
705808
}
706809

707-
// Rest of body_vec are patterns or rule invocations
708-
let body_clauses = body_vec[1..].to_vec();
810+
// Rest of body_vec are patterns, rule invocations, or (not ...) clauses
811+
let mut body_clauses: Vec<WhereClause> = Vec::new();
812+
for item in &body_vec[1..] {
813+
if let Some(vec) = item.as_vector() {
814+
let pattern = Pattern::from_edn(vec)?;
815+
body_clauses.push(WhereClause::Pattern(pattern));
816+
} else if let Some(list) = item.as_list() {
817+
let clause = parse_list_as_where_clause(list, true)?;
818+
body_clauses.push(clause);
819+
} else {
820+
return Err(format!(
821+
"Rule body clause must be a vector (pattern) or list (rule invocation / not), got {:?}",
822+
item
823+
));
824+
}
825+
}
709826

710827
if body_clauses.is_empty() {
711828
return Err("Rule must have at least one pattern or rule invocation in body".to_string());
712829
}
713830

831+
// Safety check: variables in (not ...) must be bound by the rule head or outer body clauses
832+
let mut outer_bound: std::collections::HashSet<String> = std::collections::HashSet::new();
833+
// Head args count as binding sites
834+
for v in &head_list[1..] {
835+
if let Some(name) = v.as_variable() {
836+
outer_bound.insert(name.to_string());
837+
}
838+
}
839+
// Non-not body clauses
840+
for clause in &body_clauses {
841+
for var in outer_vars_from_clause(clause) {
842+
outer_bound.insert(var);
843+
}
844+
}
845+
check_not_safety(&body_clauses, &outer_bound)?;
846+
714847
Ok(DatalogCommand::Rule(Rule {
715848
head: head_list.clone(),
716849
body: body_clauses,
@@ -894,11 +1027,11 @@ mod tests {
8941027
// Verify body has two clauses: pattern + rule invocation
8951028
assert_eq!(rule.body.len(), 2);
8961029

897-
// First clause should be a vector (pattern)
898-
assert!(rule.body[0].as_vector().is_some());
1030+
// First clause should be a Pattern
1031+
assert!(matches!(rule.body[0], WhereClause::Pattern(_)));
8991032

900-
// Second clause should be a list (rule invocation)
901-
assert!(rule.body[1].as_list().is_some());
1033+
// Second clause should be a RuleInvocation
1034+
assert!(matches!(rule.body[1], WhereClause::RuleInvocation { .. }));
9021035
}
9031036
_ => panic!("Expected Rule command"),
9041037
}
@@ -914,8 +1047,8 @@ mod tests {
9141047
assert_eq!(rule.head[0], EdnValue::Symbol("ancestor".to_string()));
9151048
// Two patterns in body
9161049
assert_eq!(rule.body.len(), 2);
917-
assert!(rule.body[0].as_vector().is_some());
918-
assert!(rule.body[1].as_vector().is_some());
1050+
assert!(matches!(rule.body[0], WhereClause::Pattern(_)));
1051+
assert!(matches!(rule.body[1], WhereClause::Pattern(_)));
9191052
}
9201053
_ => panic!("Expected Rule command"),
9211054
}
@@ -1169,4 +1302,113 @@ mod tests {
11691302
assert!(matches!(query.as_of, Some(AsOf::Counter(100))));
11701303
assert!(matches!(query.valid_at, Some(ValidAt::Timestamp(_))));
11711304
}
1305+
1306+
#[test]
1307+
fn test_parse_not_with_pattern_in_query() {
1308+
let input = r#"(query [:find ?person :where [?person :name ?n] (not [?person :banned true])])"#;
1309+
let cmd = parse_datalog_command(input).unwrap();
1310+
match cmd {
1311+
DatalogCommand::Query(q) => {
1312+
assert_eq!(q.where_clauses.len(), 2);
1313+
assert!(matches!(q.where_clauses[0], WhereClause::Pattern(_)));
1314+
match &q.where_clauses[1] {
1315+
WhereClause::Not(inner) => {
1316+
assert_eq!(inner.len(), 1);
1317+
assert!(matches!(inner[0], WhereClause::Pattern(_)));
1318+
}
1319+
other => panic!("Expected Not, got {:?}", other),
1320+
}
1321+
}
1322+
_ => panic!("Expected Query"),
1323+
}
1324+
}
1325+
1326+
#[test]
1327+
fn test_parse_not_with_rule_invocation_in_query() {
1328+
let input = r#"(query [:find ?person :where [?person :name ?n] (not (blocked ?person))])"#;
1329+
let cmd = parse_datalog_command(input).unwrap();
1330+
match cmd {
1331+
DatalogCommand::Query(q) => {
1332+
match &q.where_clauses[1] {
1333+
WhereClause::Not(inner) => {
1334+
assert!(matches!(inner[0], WhereClause::RuleInvocation { .. }));
1335+
}
1336+
other => panic!("Expected Not, got {:?}", other),
1337+
}
1338+
}
1339+
_ => panic!("Expected Query"),
1340+
}
1341+
}
1342+
1343+
#[test]
1344+
fn test_parse_not_in_rule_body() {
1345+
let input = r#"(rule [(eligible ?x) [?x :applied true] (not (rejected ?x))])"#;
1346+
let cmd = parse_datalog_command(input).unwrap();
1347+
match cmd {
1348+
DatalogCommand::Rule(rule) => {
1349+
assert_eq!(rule.body.len(), 2);
1350+
assert!(matches!(rule.body[0], WhereClause::Pattern(_)));
1351+
assert!(matches!(rule.body[1], WhereClause::Not(_)));
1352+
}
1353+
_ => panic!("Expected Rule"),
1354+
}
1355+
}
1356+
1357+
#[test]
1358+
fn test_parse_not_empty_body_is_error() {
1359+
let input = r#"(query [:find ?x :where [?x :a ?v] (not)])"#;
1360+
let result = parse_datalog_command(input);
1361+
assert!(result.is_err());
1362+
let msg = result.unwrap_err();
1363+
assert!(msg.contains("requires at least one clause"), "got: {msg}");
1364+
}
1365+
1366+
#[test]
1367+
fn test_parse_nested_not_is_error() {
1368+
let input = r#"(query [:find ?x :where [?x :a ?v] (not (not [?x :banned true]))])"#;
1369+
let result = parse_datalog_command(input);
1370+
assert!(result.is_err());
1371+
let msg = result.unwrap_err();
1372+
assert!(msg.contains("cannot appear inside another"), "got: {msg}");
1373+
}
1374+
1375+
#[test]
1376+
fn test_parse_not_unbound_variable_is_error() {
1377+
// ?y is only in the not body, not in any outer clause
1378+
let input = r#"(query [:find ?x :where [?x :a ?v] (not [?y :banned true])])"#;
1379+
let result = parse_datalog_command(input);
1380+
assert!(result.is_err());
1381+
let msg = result.unwrap_err();
1382+
assert!(msg.contains("not bound"), "got: {msg}");
1383+
}
1384+
1385+
#[test]
1386+
fn test_parse_not_unbound_variable_in_rule_body_is_error() {
1387+
// ?y only in not, not in head or non-not body
1388+
let input = r#"(rule [(eligible ?x) [?x :applied true] (not [?y :banned true])])"#;
1389+
let result = parse_datalog_command(input);
1390+
assert!(result.is_err());
1391+
let msg = result.unwrap_err();
1392+
assert!(msg.contains("not bound"), "got: {msg}");
1393+
}
1394+
1395+
#[test]
1396+
fn test_parse_not_with_multiple_clauses() {
1397+
// (not [?person :role :admin] [?person :active false])
1398+
let input = r#"(query [:find ?person :where [?person :name ?n] (not [?person :role :admin] [?person :active false])])"#;
1399+
let cmd = parse_datalog_command(input).unwrap();
1400+
match cmd {
1401+
DatalogCommand::Query(q) => {
1402+
match &q.where_clauses[1] {
1403+
WhereClause::Not(inner) => {
1404+
assert_eq!(inner.len(), 2);
1405+
assert!(matches!(inner[0], WhereClause::Pattern(_)));
1406+
assert!(matches!(inner[1], WhereClause::Pattern(_)));
1407+
}
1408+
other => panic!("Expected Not with 2 clauses, got {:?}", other),
1409+
}
1410+
}
1411+
_ => panic!("Expected Query"),
1412+
}
1413+
}
11721414
}

0 commit comments

Comments
 (0)