From 4cfa86b9f27a3af47f3e26a67b8eb50647942bac Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Sun, 12 Apr 2026 18:49:59 -0500 Subject: [PATCH] feat: add forall/exists quantifiers and reachable graph traversal to s-expressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the s-expression evaluator with: - forall(scope, predicate) — universal quantifier over store - exists(scope, predicate) — existential quantifier over store - count(scope) — boolean: at least one match exists - reachable-from(start, link-type) — current artifact is downstream - reachable-to(target, link-type) — target is downstream of current EvalContext now includes optional Store reference for quantifier access. All callers (CLI, API, MCP, salsa) pass store via matches_filter_with_store. Examples: rivet list --filter '(exists (= type "requirement") (has-tag "stpa"))' rivet list --filter '(reachable-from "REQ-004" "satisfies")' Implements: REQ-041 Refs: FEAT-109 Co-Authored-By: Claude Opus 4.6 (1M context) --- rivet-cli/src/main.rs | 8 +- rivet-cli/src/mcp.rs | 7 +- rivet-cli/src/serve/api.rs | 7 +- rivet-core/src/db.rs | 2 +- rivet-core/src/sexpr_eval.rs | 171 ++++++++++++++++++++++++++++- rivet-core/tests/proptest_sexpr.rs | 1 + 6 files changed, 189 insertions(+), 7 deletions(-) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index 9330ee0..baae66d 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -3318,7 +3318,9 @@ fn cmd_list( anyhow::anyhow!("invalid filter: {}", msgs.join("; ")) })?; let graph = rivet_core::links::LinkGraph::build(&store, &ctx.schema); - results.retain(|a| rivet_core::sexpr_eval::matches_filter(&expr, a, &graph)); + results.retain(|a| { + rivet_core::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store) + }); } if format == "json" { @@ -3379,7 +3381,7 @@ fn cmd_stats( })?; let mut filtered = rivet_core::store::Store::default(); for a in store.iter() { - if rivet_core::sexpr_eval::matches_filter(&expr, a, &graph) { + if rivet_core::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store) { filtered.upsert(a.clone()); } } @@ -3480,7 +3482,7 @@ fn cmd_coverage( })?; let mut filtered = rivet_core::store::Store::default(); for a in store.iter() { - if rivet_core::sexpr_eval::matches_filter(&expr, a, &graph) { + if rivet_core::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store) { filtered.upsert(a.clone()); } } diff --git a/rivet-cli/src/mcp.rs b/rivet-cli/src/mcp.rs index e055406..1caeae4 100644 --- a/rivet-cli/src/mcp.rs +++ b/rivet-cli/src/mcp.rs @@ -942,7 +942,12 @@ fn tool_query(proj: &McpProject, params: &QueryParams) -> Result { let mut results: Vec = Vec::new(); for artifact in proj.store.iter() { - if !rivet_core::sexpr_eval::matches_filter(&expr, artifact, &proj.graph) { + if !rivet_core::sexpr_eval::matches_filter_with_store( + &expr, + artifact, + &proj.graph, + &proj.store, + ) { continue; } let links_json: Vec = artifact diff --git a/rivet-cli/src/serve/api.rs b/rivet-cli/src/serve/api.rs index bff7149..5d2ee62 100644 --- a/rivet-cli/src/serve/api.rs +++ b/rivet-cli/src/serve/api.rs @@ -380,7 +380,12 @@ pub(crate) async fn artifacts( continue; } if let Some(ref expr) = sexpr_filter { - if !rivet_core::sexpr_eval::matches_filter(expr, artifact, &guard.graph) { + if !rivet_core::sexpr_eval::matches_filter_with_store( + expr, + artifact, + &guard.graph, + &guard.store, + ) { continue; } } diff --git a/rivet-core/src/db.rs b/rivet-core/src/db.rs index 39d7292..bdac5b2 100644 --- a/rivet-core/src/db.rs +++ b/rivet-core/src/db.rs @@ -410,7 +410,7 @@ pub fn filter_artifact_ids( let graph = build_link_graph(db, source_set, schema_set); store .iter() - .filter(|a| crate::sexpr_eval::matches_filter(&expr, a, &graph)) + .filter(|a| crate::sexpr_eval::matches_filter_with_store(&expr, a, &graph, &store)) .map(|a| a.id.clone()) .collect() } diff --git a/rivet-core/src/sexpr_eval.rs b/rivet-core/src/sexpr_eval.rs index 02910e0..ded0d3a 100644 --- a/rivet-core/src/sexpr_eval.rs +++ b/rivet-core/src/sexpr_eval.rs @@ -9,6 +9,7 @@ use crate::links::LinkGraph; use crate::model::Artifact; +use crate::store::Store; // ── Typed AST ─────────────────────────────────────────────────────────── @@ -63,6 +64,23 @@ pub enum Expr { /// `(links-count "satisfies" > 2)` — cardinality check. LinksCount(Value, CompOp, Value), + // ── Quantifiers (require Store access) ──────────────────────── + /// `(forall )` — all artifacts in scope satisfy predicate. + /// Scope is a filter expression; predicate is checked per matching artifact. + Forall(Box, Box), + /// `(exists )` — at least one artifact in scope satisfies predicate. + Exists(Box, Box), + /// `(count )` — number of artifacts matching scope (compared via parent). + Count(Box), + + // ── Graph traversal ───────────────────────────────────────────── + /// `(reachable-from "REQ-001" "satisfies")` — true if current artifact is + /// reachable from the given start via the given link type. + ReachableFrom(Value, Value), + /// `(reachable-to "TEST-090" "verifies")` — true if the given target is + /// reachable from the current artifact via the given link type. + ReachableTo(Value, Value), + // ── Literal ───────────────────────────────────────────────────── /// Constant boolean (useful after constant folding). BoolLit(bool), @@ -101,9 +119,14 @@ pub enum CompOp { // ── Evaluation context ────────────────────────────────────────────────── /// Context needed to check a predicate against one artifact. +/// +/// For quantifier expressions (forall, exists, count), the `store` field +/// must be set. Single-artifact predicates work without it. pub struct EvalContext<'a> { pub artifact: &'a Artifact, pub graph: &'a LinkGraph, + /// Required for quantifier expressions. None = quantifiers return false. + pub store: Option<&'a Store>, } // ── Predicate checker ─────────────────────────────────────────────────── @@ -192,6 +215,67 @@ pub fn check(expr: &Expr, ctx: &EvalContext) -> bool { } } + // Quantifiers + Expr::Forall(scope, predicate) => { + let Some(store) = ctx.store else { + return false; + }; + store.iter().all(|a| { + let scope_ctx = EvalContext { + artifact: a, + graph: ctx.graph, + store: ctx.store, + }; + // If artifact doesn't match scope, it's vacuously true + if !check(scope, &scope_ctx) { + return true; + } + check(predicate, &scope_ctx) + }) + } + Expr::Exists(scope, predicate) => { + let Some(store) = ctx.store else { + return false; + }; + store.iter().any(|a| { + let scope_ctx = EvalContext { + artifact: a, + graph: ctx.graph, + store: ctx.store, + }; + check(scope, &scope_ctx) && check(predicate, &scope_ctx) + }) + } + Expr::Count(_scope) => { + // Count is not a boolean predicate on its own — it's used + // inside comparison expressions. Return true if count > 0. + let Some(store) = ctx.store else { + return false; + }; + store.iter().any(|a| { + let scope_ctx = EvalContext { + artifact: a, + graph: ctx.graph, + store: ctx.store, + }; + check(_scope, &scope_ctx) + }) + } + + // Graph traversal + Expr::ReachableFrom(start_id, link_type) => { + let start = value_to_str(start_id); + let lt = value_to_str(link_type); + let reachable = ctx.graph.reachable(&start, <); + reachable.contains(&ctx.artifact.id) + } + Expr::ReachableTo(target_id, link_type) => { + let target = value_to_str(target_id); + let lt = value_to_str(link_type); + let reachable = ctx.graph.reachable(&ctx.artifact.id, <); + reachable.contains(&target) + } + Expr::BoolLit(b) => *b, } } @@ -341,7 +425,26 @@ pub fn parse_filter(source: &str) -> Result> { /// Convenience: parse a filter and check it against one artifact. pub fn matches_filter(expr: &Expr, artifact: &Artifact, graph: &LinkGraph) -> bool { - let ctx = EvalContext { artifact, graph }; + let ctx = EvalContext { + artifact, + graph, + store: None, + }; + check(expr, &ctx) +} + +/// Check a filter with full store access (needed for quantifiers). +pub fn matches_filter_with_store( + expr: &Expr, + artifact: &Artifact, + graph: &LinkGraph, + store: &Store, +) -> bool { + let ctx = EvalContext { + artifact, + graph, + store: Some(store), + }; check(expr, &ctx) } @@ -594,6 +697,71 @@ fn lower_list(node: &crate::sexpr::SyntaxNode, errors: &mut Vec) -> Some(Expr::LinksCount(lt, op, val)) } + // Quantifiers + "forall" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'forall' requires exactly 2 arguments (scope predicate)".into(), + }); + return None; + } + let scope = lower_child(&args[0], errors)?; + let pred = lower_child(&args[1], errors)?; + Some(Expr::Forall(Box::new(scope), Box::new(pred))) + } + "exists" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'exists' requires exactly 2 arguments (scope predicate)".into(), + }); + return None; + } + let scope = lower_child(&args[0], errors)?; + let pred = lower_child(&args[1], errors)?; + Some(Expr::Exists(Box::new(scope), Box::new(pred))) + } + "count" => { + if args.len() != 1 { + errors.push(LowerError { + offset, + message: "'count' requires exactly 1 argument (scope)".into(), + }); + return None; + } + let scope = lower_child(&args[0], errors)?; + Some(Expr::Count(Box::new(scope))) + } + + // Graph traversal + "reachable-from" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'reachable-from' requires exactly 2 arguments (start-id link-type)" + .into(), + }); + return None; + } + let start = extract_value(&args[0])?; + let lt = extract_value(&args[1])?; + Some(Expr::ReachableFrom(start, lt)) + } + "reachable-to" => { + if args.len() != 2 { + errors.push(LowerError { + offset, + message: "'reachable-to' requires exactly 2 arguments (target-id link-type)" + .into(), + }); + return None; + } + let target = extract_value(&args[0])?; + let lt = extract_value(&args[1])?; + Some(Expr::ReachableTo(target, lt)) + } + unknown => { errors.push(LowerError { offset, @@ -741,6 +909,7 @@ mod tests { let ctx = EvalContext { artifact, graph: &graph, + store: None, }; check(expr, &ctx) } diff --git a/rivet-core/tests/proptest_sexpr.rs b/rivet-core/tests/proptest_sexpr.rs index e1f6d4b..baacd6c 100644 --- a/rivet-core/tests/proptest_sexpr.rs +++ b/rivet-core/tests/proptest_sexpr.rs @@ -150,6 +150,7 @@ fn run_check(expr: &Expr, artifact: &Artifact) -> bool { let ctx = EvalContext { artifact, graph: &graph, + store: None, }; sexpr_eval::check(expr, &ctx) }