Problem
reaches() with its default depth=None does not terminate on a large call graph. Measured on a codeanalyzer-typescript 1.3.0 projection of microsoft/vscode's src/ tree — 9,005 files, 2.45M nodes:
MATCH (a:TSCallable {signature:$a}) WHERE …
MATCH (a) ((x:TSCallable)-[:TS_CALLS]->(y:TSCallable) WHERE …){1,} (m:TSCallable)
WITH DISTINCT m WHERE … AND m.signature = $b RETURN count(m) > 0 AS ok
44 minutes, then I terminated it unfinished. The same question, same pair, same database:
| form |
depth |
time |
count(shortestPath((a)-[:TS_CALLS*1..]->(b))) > 0 |
unbounded |
1.5 s |
| shipped quantified form |
{1,8} |
~2 s |
| shipped quantified form |
{1,} (the default) |
44 min, unfinished |
Bounded depths are fine at every value I tried up to 8. It is specifically the unbounded quantifier.
Why
A quantified path pattern has trail semantics — Neo4j enumerates distinct-relationship paths. WITH DISTINCT m deduplicates the result, after enumeration; it does not prune during traversal. On a cyclic call graph the number of trails grows combinatorially with the bound, so a finite bound stays cheap and no bound at all does not finish.
The comment above the Python copy states the opposite, and cites a measurement that no longer generalises:
the quantified path pattern below … still plans as a pruning expansion (WITH DISTINCT m keeps it one) … at 0.25s against 0.03s for the unsafe form
That 0.25 s was measured on the odoo graph. It is true there and false at vscode scale, which is exactly why this went unnoticed: every corpus the suites have run against so far has a call graph small enough for trail enumeration to finish.
Where
Two shapes, both affected, in the languages that answer them over Cypher:
| accessor |
Python |
TypeScript |
Java |
reaches (call graph) |
_REACHES, quantified {1,{depth}} |
_REACHES, same |
not affected — answered in memory over networkx |
flows_to_call / flows_to_argument (SDG) |
_VALUE_REACHES, var-length *1..{depth} |
_VALUE_REACHES, same |
_VALUE_REACHES, same |
Variable-length patterns carry the same trail semantics as quantified ones, so _VALUE_REACHES is the same defect in a different spelling. It has been unreachable on Java until now (the port lattice was disconnected), which means codeanalyzer-java 3.0.3 turns on a code path with this shape in it.
Scope boundary
In scope: make the unbounded reachability predicate use a pruning primitive on every backend that answers it over Cypher, and correct the comments that claim the current form prunes.
Out of scope: paths_between and call_paths_between, which already use allShortestPaths and are not affected. The bounded case, which is correct and cheap today.
Goals
Caveats and known risks
shortestPath answers "is there a path", which is what these predicates ask, but it is not a drop-in for anything that needs the path itself — check each call site is only consuming the boolean.
- The bounded form must keep working: a caller passing
depth is asking a different, narrower question and its cost is already acceptable.
- Any wall-clock assertion must be paused around the coverage tracer, as the existing
timed marker does — a run under instrumentation measures the tracer.
Definition of done
- An unbounded
reaches on a million-node graph returns in seconds rather than not at all, on all three backends, and a test pins it.
Problem
reaches()with its defaultdepth=Nonedoes not terminate on a large call graph. Measured on a codeanalyzer-typescript 1.3.0 projection ofmicrosoft/vscode'ssrc/tree — 9,005 files, 2.45M nodes:44 minutes, then I terminated it unfinished. The same question, same pair, same database:
count(shortestPath((a)-[:TS_CALLS*1..]->(b))) > 0{1,8}{1,}(the default)Bounded depths are fine at every value I tried up to 8. It is specifically the unbounded quantifier.
Why
A quantified path pattern has trail semantics — Neo4j enumerates distinct-relationship paths.
WITH DISTINCT mdeduplicates the result, after enumeration; it does not prune during traversal. On a cyclic call graph the number of trails grows combinatorially with the bound, so a finite bound stays cheap and no bound at all does not finish.The comment above the Python copy states the opposite, and cites a measurement that no longer generalises:
That 0.25 s was measured on the odoo graph. It is true there and false at vscode scale, which is exactly why this went unnoticed: every corpus the suites have run against so far has a call graph small enough for trail enumeration to finish.
Where
Two shapes, both affected, in the languages that answer them over Cypher:
reaches(call graph)_REACHES, quantified{1,{depth}}_REACHES, samenetworkxflows_to_call/flows_to_argument(SDG)_VALUE_REACHES, var-length*1..{depth}_VALUE_REACHES, same_VALUE_REACHES, sameVariable-length patterns carry the same trail semantics as quantified ones, so
_VALUE_REACHESis the same defect in a different spelling. It has been unreachable on Java until now (the port lattice was disconnected), which means codeanalyzer-java 3.0.3 turns on a code path with this shape in it.Scope boundary
In scope: make the unbounded reachability predicate use a pruning primitive on every backend that answers it over Cypher, and correct the comments that claim the current form prunes.
Out of scope:
paths_betweenandcall_paths_between, which already useallShortestPathsand are not affected. The bounded case, which is correct and cheap today.Goals
reachesand_VALUE_REACHESanswer in bounded time withdepth=Noneon a graph of at least a million nodes, on Python, TypeScript and JavaWITH DISTINCT mmakes this a pruning BFS are corrected or removedreachesagainst a corpus large enough to enumerate, with a wall-clock boundCaveats and known risks
shortestPathanswers "is there a path", which is what these predicates ask, but it is not a drop-in for anything that needs the path itself — check each call site is only consuming the boolean.depthis asking a different, narrower question and its cost is already acceptable.timedmarker does — a run under instrumentation measures the tracer.Definition of done
reacheson a million-node graph returns in seconds rather than not at all, on all three backends, and a test pins it.