From 1348685ff4a6baaef51be3b7237ed3594a7df719 Mon Sep 17 00:00:00 2001 From: Ragnor Comerford Date: Tue, 9 Jun 2026 14:06:37 +0200 Subject: [PATCH] test(engine): cover cycle/self-loop termination + nested anti-join (C5 edge cases) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - variable_hops_terminate_and_dedup_on_cycle: a 3-cycle a->b->c->a traversed with knows{1,5} (ceiling above the cycle length) terminates and emits each node once (the c->a back-edge hits the seeded source); both_modes confirms indexed == csr. Uses a bounded range deliberately — unbounded {1,} is a typecheck error, not a runtime path. - variable_hops_handle_self_loop: a->a self-loop does not loop forever and does not re-emit the seeded source. - nested_anti_join_double_negation: not { worksAt; not { name = Acme } } recurses through execute_pipeline, yielding [Alice,Charlie,Diana] (people with no non-Acme employer) — distinct from plain unemployed [Charlie,Diana]. --- crates/omnigraph/tests/traversal.rs | 39 +++++++++++++++ crates/omnigraph/tests/traversal_indexed.rs | 54 +++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/crates/omnigraph/tests/traversal.rs b/crates/omnigraph/tests/traversal.rs index 6efe7de..de68ee6 100644 --- a/crates/omnigraph/tests/traversal.rs +++ b/crates/omnigraph/tests/traversal.rs @@ -46,6 +46,45 @@ query not_at_acme() { assert_eq!(names_vec, vec!["Bob", "Charlie", "Diana"]); } +// Nested anti-join (double negation): proves `not { … not { … } }` recurses +// through execute_pipeline. "People who do NOT work at any NON-Acme company": +// inner `not { $c.name = "Acme" }` keeps the non-Acme employers, the outer `not` +// removes anyone who has one. Alice (Acme only), Charlie & Diana (no employer) +// remain — distinct from plain unemployed {Charlie, Diana}. +#[tokio::test] +async fn nested_anti_join_double_negation() { + let dir = tempfile::tempdir().unwrap(); + let mut db = init_and_load(&dir).await; + + let queries = r#" +query no_nonacme_employer() { + match { + $p: Person + not { + $p worksAt $c + not { + $c.name = "Acme" + } + } + } + return { $p.name } +} +"#; + let result = query_main(&mut db, queries, "no_nonacme_employer", &ParamMap::new()) + .await + .unwrap(); + + let batch = result.concat_batches().unwrap(); + let names = batch + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + let mut names_vec: Vec<&str> = (0..names.len()).map(|i| names.value(i)).collect(); + names_vec.sort(); + assert_eq!(names_vec, vec!["Alice", "Charlie", "Diana"]); +} + // ─── Variable-length hops ─────────────────────────────────────────────────── const CHAIN_SCHEMA: &str = r#" diff --git a/crates/omnigraph/tests/traversal_indexed.rs b/crates/omnigraph/tests/traversal_indexed.rs index ab68ff1..00108e3 100644 --- a/crates/omnigraph/tests/traversal_indexed.rs +++ b/crates/omnigraph/tests/traversal_indexed.rs @@ -233,3 +233,57 @@ query reach($name: String) { result means the id-string collision bled across types" ); } + +const REACH_5: &str = r#" +query reach($name: String) { + match { + $p: Person { name: $name } + $p knows{1,5} $f + } + return { $f.name } +} +"#; + +// A directed 3-cycle a->b->c->a, traversed with a hop ceiling (5) ABOVE the cycle +// length. Variable-length traversal must terminate and dedup (the source is +// seeded into `visited`, so the c->a back-edge does not re-emit a). Uses a +// bounded range deliberately: an unbounded `{1,}` is a typecheck error, not a +// runtime path. `both_modes` also confirms indexed == csr on the cycle. +#[tokio::test] +#[serial] +async fn variable_hops_terminate_and_dedup_on_cycle() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().to_str().unwrap(); + let data = r#"{"type":"Person","data":{"name":"a"}} +{"type":"Person","data":{"name":"b"}} +{"type":"Person","data":{"name":"c"}} +{"edge":"Knows","from":"a","to":"b"} +{"edge":"Knows","from":"b","to":"c"} +{"edge":"Knows","from":"c","to":"a"}"#; + let mut db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap(); + load_jsonl(&mut db, data, LoadMode::Overwrite).await.unwrap(); + + let got = both_modes(&mut db, REACH_5, "reach", ¶ms(&[("$name", "a")])).await; + // From a: b (1 hop), c (2 hops); the c->a back-edge hits the seeded source + // and is not re-emitted. No infinite loop, each node at most once. + assert_eq!(got, vec!["b", "c"]); +} + +// A self-loop a->a plus a->b. Variable-length traversal must not loop forever and +// must not re-emit the seeded source. +#[tokio::test] +#[serial] +async fn variable_hops_handle_self_loop() { + let dir = tempfile::tempdir().unwrap(); + let uri = dir.path().to_str().unwrap(); + let data = r#"{"type":"Person","data":{"name":"a"}} +{"type":"Person","data":{"name":"b"}} +{"edge":"Knows","from":"a","to":"a"} +{"edge":"Knows","from":"a","to":"b"}"#; + let mut db = Omnigraph::init(uri, TEST_SCHEMA).await.unwrap(); + load_jsonl(&mut db, data, LoadMode::Overwrite).await.unwrap(); + + let got = both_modes(&mut db, REACH_5, "reach", ¶ms(&[("$name", "a")])).await; + // a->a hits the seeded source (pruned); only b is reached. + assert_eq!(got, vec!["b"]); +}