feat(gq): undirected edge traversal — $a <edge> $b#336
Merged
Conversation
iss-gq-undirected-traversal, the Expand-internal design (no plan-level
Union; unblocked from iss-744/579): the grammar gains an undirected_edge
alternative in the traversal rule (angle brackets are collision-free —
comparisons live in the structurally separate filter production), the
AST Traversal carries `undirected`, Direction gains `Both`, and
typecheck resolves undirected patterns to Both after the new T22 rule:
undirected requires a same-endpoint-type edge (an asymmetric edge is
well-typed in at most one orientation, so the form is rejected with
guidance to use the directional pattern). Lowering passes Both through;
the reverse-expand orientation flip is a no-op for a symmetric
traversal. Bounds ({min,max}) and not{} compose unchanged.
Direction has no serde derives and IR never crosses a wire — no
compatibility surface. Parser/typecheck tests cover the bare, bounded,
inside-not forms, Both resolution on Knows (Person->Person), and the
T22 rejection on WorksAt (Person->Company).
CSR arm: an undirected hop chains csr(edge).neighbors with
csc(edge).neighbors under the existing visited/seen_dst gates, so
both-direction pairs and self-loops dedup for free (set semantics),
including per-hop in variable-length BFS. Indexed arm: endpoint_probes
returns both (src,dst) and (dst,src) orientations for Both; the per-hop
scan runs once per orientation into one neighbor_map, deduped by the
existing per-source seen_dst sets. Bulk anti-join: "no edge in EITHER
direction" (csr || csc has_neighbors).
Also fixes a lowering gap the anti-join test caught red: negation
inners are typechecked into a discarded context clone, so the
ResolvedTraversal direction lookup silently fell back to Out inside
not{} — undirectedness now travels on the AST node itself (the syntax
is the source of truth), immune to context-propagation gaps.
Tests: traversal.rs (out ∪ in vs the directional miss, both-ways dedup,
var-length from an incoming-only node, undirected anti-join vs
directional), traversal_indexed.rs both_modes equivalence, and the
proptest arm-equivalence property widened with <knows>{1,3}.
…22 rule, execution cost
…nti-join arm grouping Devin: the cost model's index-coverage probe checked only the primary endpoint column, so an undirected traversal with a degraded dst index was priced as fully indexed; coverage is now the pessimistic combination over every column endpoint_probes will scan. Greptile: the bulk anti-join grouped Both with In for src_type_name but with Out for the adjacency — safe only under T22's from==to guarantee; Both now groups with Out in both arms so a future T22 relaxation cannot split them silently.
…erage
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements
iss-gq-undirected-traversalvia the cheap Expand-internal design — no dependency on iss-744/579's Union machinery (direction is handled inside the operator; alternation will compose later by carrying the flag per branch).What ships
$a <edgeName> $bmatches the edge in either direction, with set semantics (both-ways pairs and self-loops appear once). Composes with hop bounds (<knows>{1,3}) andnot { }("no edge in either direction"). Only valid on same-endpoint-type edges — an asymmetric edge (e.g.WorksAt: Person -> Company) is rejected at typecheck with the new T22 (it's well-typed in at most one orientation; the error points at the directional form).Why it's cheap
The machinery already existed: the IR's
Directionenum gainsBoth; the topology index always builds both CSR and CSC; the indexed arm already had the reverse probe (("dst","src")); the per-source dedup gates already guard emission on both arms. The angle-bracket token is collision-free (comparisons live in the structurally separatefilterproduction).A real bug found red-first
The undirected anti-join test failed against the naive implementation and exposed a lowering gap: negation inners are typechecked into a discarded context clone, so the direction lookup silently fell back to
Outinsidenot { }. Fix: undirectedness travels on the AST node itself — the syntax is the source of truth, immune to context-propagation gaps.Verification
Knows, T22 onWorksAt), 254 tests green.traversal.rs(directional-miss demonstration + out ∪ in, both-ways dedup, var-length from an incoming-only node, undirected vs directional anti-join),traversal_indexed.rsCSR==indexed, andproptest_equivalence.rswidened with<knows>{1,3}— CSR == indexed == auto over generated graphs.cargo test --workspace --lockedgreen (68 suites).Motivating case: the dev graph's
issue_relateddashboards silently missed incomingIssueRelatededges; with this, one undirected pattern replaces the two-query workaround.Greptile Summary
Adds
$a <edge> $bundirected edge traversal syntax to GQ, matching a same-endpoint-type edge in either direction with set semantics (both-ways pairs and self-loops appear once). The implementation extends the existingDirectionenum with aBothvariant and threads it through parser → typecheck → IR lowering → CSR/indexed execution paths, guarded by a new T22 typecheck error for asymmetric edges.undirected_edge = { "<" ~ edge_ident ~ ">" }is parsed intoTraversal::undirected: bool; typecheck enforces T22 (same-endpoint-type only) then overrides the inferred direction toBoth; lowering carries direction from the AST node directly (bypassing theResolvedTraversalcontext lookup that was discarded for negation inners — the root-cause fix described in the PR).execute_expand_csradditionally walks the CSC (adj_rev) per source node, chained with the CSR scan; the existingvisited/seen_dst_densegates provide correct dedup for bidirectional pairs and self-loops.execute_expand_indexediteratesendpoint_probes(Both) = [("src","dst"),("dst","src")], merging both BTREE scans into a singleneighbor_mapper hop; coverage is priced conservatively as the worst of both probed columns via the newworse_coveragehelper; the anti-join mask checks both CSR and CSC adjacency for "no edge in either direction".Confidence Score: 5/5
Safe to merge. The change adds a new syntax form with a correctness guard (T22) at typecheck, extends existing well-tested BFS machinery, and includes comprehensive tests across parser, typecheck, lowering, CSR, indexed, and property-based paths.
All three execution arms (CSR, indexed, auto) are covered by tests, including the dedup edge cases (bidirectional pairs, self-loops) and the negation-inner regression that motivated carrying direction on the AST node. The T22 guard prevents asymmetric-edge misuse at compile time. No existing behavior is altered for directional traversals.
No files require special attention.
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A["a edge b undirected syntax"] --> B["Parser: Traversal undirected=true"] B --> C{Typecheck} C -->|"from_type != to_type"| D["T22 error asymmetric edge rejected"] C -->|"from_type == to_type"| E["direction = Both ResolvedTraversal pushed"] E --> F["IR Lowering reads traversal.undirected not context lookup"] F --> G["IROp::Expand direction=Both"] G --> H{"execute_expand coverage = worse of src_col and dst_col"} H -->|indexed path| J["execute_expand_indexed probes src+dst BTREE per hop neighbor_map merges both seen_dst dedup"] H -->|CSR path| K["execute_expand_csr chain adj CSR + adj_rev CSC per frontier node visited + seen_dst dedup"] G --> L["IROp::AntiJoin not block"] L --> M["try_bulk_anti_join_mask checks both adj and adj_rev has_neighbors"]%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A["a edge b undirected syntax"] --> B["Parser: Traversal undirected=true"] B --> C{Typecheck} C -->|"from_type != to_type"| D["T22 error asymmetric edge rejected"] C -->|"from_type == to_type"| E["direction = Both ResolvedTraversal pushed"] E --> F["IR Lowering reads traversal.undirected not context lookup"] F --> G["IROp::Expand direction=Both"] G --> H{"execute_expand coverage = worse of src_col and dst_col"} H -->|indexed path| J["execute_expand_indexed probes src+dst BTREE per hop neighbor_map merges both seen_dst dedup"] H -->|CSR path| K["execute_expand_csr chain adj CSR + adj_rev CSC per frontier node visited + seen_dst dedup"] G --> L["IROp::AntiJoin not block"] L --> M["try_bulk_anti_join_mask checks both adj and adj_rev has_neighbors"]Comments Outside Diff (1)
crates/omnigraph/src/exec/query.rs, line 1050-1066 (link)Direction::Bothwork by ~2×gather_cost_inputsreturns a singleedge_countand asrc_node_countderived fromfrom_typeforDirection::Both, but the indexed path actually fires two BTREE scans per hop — once keyed bysrcand once bydst(seeendpoint_probes). The planner's~hops × frontier × fanoutcost estimate is therefore half the real indexed work for undirected traversals. This can tip the planner toward the indexed path when CSR would be cheaper for large frontiers or densely connected undirected edges.The user-facing doc update already notes "its cost is roughly twice the directional equivalent," but the cost model feeding the decision doesn't reflect this. A simple correction is to double
edge_count(or the derivedeffective_max_hops-weighted product) whendirection == Direction::Both.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Reviews (3): Last reviewed commit: "test: lowering-boundary pins for Both + ..." | Re-trigger Greptile