From 2faa8dfa3eed0569c29d325c8c8c4ce78a300f85 Mon Sep 17 00:00:00 2001 From: aaltshuler Date: Mon, 6 Jul 2026 00:16:04 +0300 Subject: [PATCH] test: lowering-boundary pins for Both + undirected under degraded coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two coverage gaps from review of my own test surface: (1) the lowering boundary — undirected lowers to Expand{direction: Both} both top-level and inside not{} (the discarded-context-clone regression is now pinned at the layer it lives at, not only end-to-end); (2) the review-fix path — undirected both_modes equivalence with a mutation-appended unindexed fragment degrading BTREE coverage, so results stay correct and mode-equivalent whichever path the pessimistic coverage pricing picks. --- .../omnigraph-compiler/src/ir/lower_tests.rs | 57 +++++++++++++++++++ crates/omnigraph/tests/traversal_indexed.rs | 36 ++++++++++++ 2 files changed, 93 insertions(+) diff --git a/crates/omnigraph-compiler/src/ir/lower_tests.rs b/crates/omnigraph-compiler/src/ir/lower_tests.rs index 7aa140ee..de5f69ef 100644 --- a/crates/omnigraph-compiler/src/ir/lower_tests.rs +++ b/crates/omnigraph-compiler/src/ir/lower_tests.rs @@ -39,6 +39,63 @@ return { $f.name, $f.age } assert_eq!(ir.return_exprs.len(), 2); } +#[test] +fn test_lower_undirected_traversal_to_direction_both() { + let catalog = setup(); + let qf = parse_query( + r#" +query q($name: String) { +match { + $p: Person { name: $name } + $p $f +} +return { $f.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + match &ir.pipeline[1] { + IROp::Expand { direction, .. } => assert_eq!(*direction, Direction::Both), + op => panic!("expected Expand, got {op:?}"), + } +} + +// The discarded-context-clone regression: negation inners are typechecked into +// a clone that never reaches lowering's ResolvedTraversal lookup, so direction +// used to silently fall back to Out inside not{}. Undirectedness now travels +// on the AST node; this pins Both surviving into the AntiJoin's inner Expand. +#[test] +fn test_lower_undirected_inside_negation_keeps_direction_both() { + let catalog = setup(); + let qf = parse_query( + r#" +query q() { +match { + $p: Person + not { $p $_ } +} +return { $p.name } +} +"#, + ) + .unwrap(); + let tc = typecheck_query(&catalog, &qf.queries[0]).unwrap(); + let ir = lower_query(&catalog, &qf.queries[0], &tc).unwrap(); + let IROp::AntiJoin { inner, .. } = &ir.pipeline[1] else { + panic!("expected AntiJoin, got {:?}", ir.pipeline[1]); + }; + match &inner[0] { + IROp::Expand { direction, .. } => assert_eq!( + *direction, + Direction::Both, + "negation inner must not fall back to Out" + ), + op => panic!("expected inner Expand, got {op:?}"), + } +} + #[test] fn test_lower_negation() { let catalog = setup(); diff --git a/crates/omnigraph/tests/traversal_indexed.rs b/crates/omnigraph/tests/traversal_indexed.rs index 805c295b..ae500078 100644 --- a/crates/omnigraph/tests/traversal_indexed.rs +++ b/crates/omnigraph/tests/traversal_indexed.rs @@ -141,6 +141,42 @@ query connected($name: String) { assert_eq!(got, vec!["Alice", "Diana"], "out ∪ in neighbors of Bob"); } +/// The undirected cost fix (review follow-up): coverage is priced by the +/// WORST of the two probed columns. This test degrades coverage via a +/// mutation-appended unindexed fragment and asserts undirected results stay +/// correct and mode-equivalent regardless of which path the cost model picks. +#[tokio::test] +async fn indexed_matches_csr_undirected_under_degraded_coverage() { + let dir = tempfile::tempdir().unwrap(); + let mut db = init_and_load(&dir).await; + + // Append an edge through the mutation path — an unindexed fragment that + // degrades BTREE coverage on the Knows table (pinned by the coverage test + // above). Diana->Alice also gives Alice a NEW incoming edge that only the + // undirected form can see from Alice's side. + mutate_main( + &mut db, + MUTATION_QUERIES, + "add_friend", + ¶ms(&[("$from", "Diana"), ("$to", "Alice")]), + ) + .await + .unwrap(); + + let queries = r#" +query connected($name: String) { + match { + $p: Person { name: $name } + $p $f + } + return { $f.name } +} +"#; + // Alice: outgoing Bob, Charlie; incoming Diana (the fresh unindexed edge). + let got = both_modes(&mut db, queries, "connected", ¶ms(&[("$name", "Alice")])).await; + assert_eq!(got, vec!["Bob", "Charlie", "Diana"]); +} + #[tokio::test] async fn indexed_matches_csr_multi_hop_same_type() { let dir = tempfile::tempdir().unwrap();