Skip to content

Commit 673f58e

Browse files
committed
interop/tinkerpop: A2 lowering strategy (Gremlin -> mask hop chain)
LanceHopStrategy is a TinkerPop ProviderOptimizationStrategy. It rewrites V(ids).out(label)^k.dedup().count() into one LanceHopCountStep answered by the lance-graph-java mask hop chain; repeat(out()).times(k) reaches the same shape. Every other root traversal is left to Gremlin, counted in fellBack() with the reason in lastFallbackReason(). LoweringStrategyTest (55 checks): parity with plain Gremlin for k = 1..3; provenance (an empty TinkerGraph still yields the row store's count when lowered, 0 when not); seven fallback shapes counted and answering exactly as Gremlin. Disable runs: removing the dedup() guard turns 6 checks red, a lowered step answering 0 turns 7 red. run.sh now runs both mains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AhMhXd2gDhYzTgwKeHaWHB
1 parent 0136005 commit 673f58e

8 files changed

Lines changed: 513 additions & 7 deletions

File tree

‎.claude/board/LATEST_STATE.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
## 2026-10-09 — Gremlin traversals lowered onto the mask hop chain (interop/tinkerpop, A2)
2+
3+
`LanceHopStrategy` is a TinkerPop provider strategy. It rewrites
4+
`V(ids).out(label)^k.dedup().count()` into one `LanceHopCountStep` answered by the
5+
lance-graph-java mask hop chain, and leaves everything else to Gremlin with a
6+
counted fallback and a reason.
7+
8+
- **Measured:** 55 checks green. Lowered results equal plain Gremlin for k = 1..3,
9+
through both `out()^k` and `repeat().times(k)`. Against an empty TinkerGraph the
10+
lowered traversal still returns the row store's count, which proves where the
11+
answer comes from.
12+
- **Found:** Gremlin turns `out().count()` into `outE().count()` before provider
13+
strategies run, so a walk count needs `barrier()` to reach the `dedup()` guard.
14+
That case is now a test, and removing the guard turns it red.
15+
116
## 2026-10-09 — Gremlin and the mask hop chain agree (interop/tinkerpop, A1)
217

318
`interop/tinkerpop/` adds a parity harness, built outside `consumers/` so the

‎.claude/board/PR_ARC_INVENTORY.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,29 @@
88
> anti-pattern the imported board rules name. Backfilled below in one
99
> pass rather than left stale; PR #4 onward gets its entry at merge time.
1010
11-
## PR (open) — A1 TinkerPop parity harness (branch `ccr-70e679fa-9qvl9y`)
11+
## PR (open) — A2 TinkerPop lowering strategy (branch `ccr-70e679fa-9qvl9y`)
1212

1313
**Entry written at open.**
1414

15+
- **Added:** `interop/tinkerpop/LanceHopStrategy` (a TinkerPop
16+
`ProviderOptimizationStrategy`), `LanceHopCountStep`, `LoweringStrategyTest`;
17+
`run.sh` now runs both mains and fails if either fails.
18+
- **Locked:** only `V(ids).out(label)^k.dedup().count()` is lowered (k >= 1, one
19+
mapped label per hop, numeric row ids within the store, no `has()` or `as()`).
20+
Every other root traversal is counted as a fallback with a reason and answers
21+
exactly as Gremlin.
22+
- **Gates:** 55 checks green (plus A1's 25). Disable runs: the `dedup()` guard
23+
removed turns 6 checks red (a walk count through `barrier()` gets lowered to a
24+
distinct count); a lowered step answering 0 turns 7 red.
25+
- **Deferred:** A3 (measure lowered vs plain Gremlin), A5 (Gremlin Server
26+
endpoint).
27+
- **Confidence:** high for correctness of the lowered shape on this fixture.
28+
Nothing yet about speed.
29+
30+
## PR #89 — A1 TinkerPop parity harness (merged `0136005`, branch `ccr-70e679fa-9qvl9y`)
31+
32+
**Entry written at open; merge recorded 2026-10-09.**
33+
1534
- **Added:** `interop/tinkerpop/`, consisting of `TinkerParityTest`,
1635
`deps.lock` (19 jars, sha256), `fetch-deps.sh`, `run.sh`, a README, and
1736
gitignore lines for `lib/` and `out/`.

‎interop/tinkerpop/README.md‎

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# interop/tinkerpop: A1 parity harness
1+
# interop/tinkerpop: A1 parity harness and A2 lowering strategy
22

33
One seeded graph, two engines, the same answers. The harness opens a
44
`RowStore.openWithEdges` fixture and copies its edges into **TinkerGraph** 3.7.7,
@@ -30,4 +30,46 @@ The `java-suites` job runs this harness in two separate steps at its end:
3030
## Rules it follows
3131

3232
- The TinkerGraph copy reads facets one row at a time (`classidAt`, `payloadHi32At`, `payloadLow64At`). That is a scalar test oracle, which lance-graph-java licenses only in test code (rule E2). It is never an execution path.
33-
- Nothing here changes `src/main`. A provider strategy that rewrites Gremlin into mask chains is the next step (A2), and it must keep a counted, visible fallback for every traversal it does not lower.
33+
- Nothing here changes `src/main`.
34+
35+
## A2: `LanceHopStrategy` (55 checks, all green)
36+
37+
A TinkerPop provider strategy. Register it on a traversal source:
38+
39+
```java
40+
GraphTraversalSource g = graph.traversal()
41+
.withStrategies(new LanceHopStrategy(store, Map.of("knows", Edge.KNOWS)));
42+
long n = g.V(seeds).out("knows").out("knows").dedup().count().next();
43+
```
44+
45+
It lowers exactly one shape, `V(ids).out(label)^k.dedup().count()` with k >= 1, onto
46+
the mask hop chain. The whole traversal becomes one `LanceHopCountStep`, which
47+
emits the count when iterated. `repeat(out(label)).times(k)` reaches the same
48+
shape because TinkerPop unrolls it first. Vertex ids are row numbers, and each
49+
edge label maps to an edge classid.
50+
51+
Everything else stays with Gremlin, and each such root traversal is counted in
52+
`fellBack()`, with the reason in `lastFallbackReason()`:
53+
54+
- a walk count (no `dedup()`), which is a bag, not a set;
55+
- `has()`, `in()`, `both()`, an unmapped label, more than one label per hop;
56+
- `V()` without ids, a non-numeric id, or an id outside the row store;
57+
- any `as()` label.
58+
59+
`LoweringStrategyTest` checks four things:
60+
61+
- **Parity:** lowered equals plain Gremlin for k = 1..3, through both `out()^k` and `repeat()`, and the traversal really ran as one `LanceHopCountStep`.
62+
- **Provenance:** against an *empty* TinkerGraph the lowered traversal still returns the row store's count, while plain Gremlin returns 0. The answer comes from the mask chain, not from Gremlin.
63+
- **Fallbacks:** each excluded shape is counted once and answers exactly as Gremlin.
64+
- **Counters:** the lowered and fallback counters move by one per root traversal.
65+
66+
Disable runs, each red then green:
67+
- remove the `dedup()` guard: the walk count through `barrier()` gets lowered and gives the distinct count instead (6 red);
68+
- make the lowered step return 0: every parity check and the provenance check go red (7 red).
69+
70+
Gremlin rewrites `out().count()` into `outE().count()` before provider strategies
71+
run, so a plain walk count never reaches the `dedup()` guard. The `barrier()`
72+
case exists to exercise that guard.
73+
74+
The strategy reads the row store it was given, not the graph the traversal
75+
source wraps. Keeping both consistent is the caller's job.

‎interop/tinkerpop/run.sh‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,13 @@ if ! "$J/javac" --release 28 --enable-preview -d "$out" -cp "$cp" \
2727
echo "compile failed" >&2
2828
exit 1
2929
fi
30-
exec "$J/java" --enable-preview --enable-native-access=ALL-UNNAMED -Dlgj.library="$SO" \
31-
-cp "$out:$cp" com.adaworldapi.interop.tinkerpop.TinkerParityTest
30+
# Run every main, then fail if any failed: one red suite must not hide the next.
31+
failed=""
32+
for main in TinkerParityTest LoweringStrategyTest; do
33+
"$J/java" --enable-preview --enable-native-access=ALL-UNNAMED -Dlgj.library="$SO" \
34+
-cp "$out:$cp" "com.adaworldapi.interop.tinkerpop.$main" || failed="$failed $main"
35+
done
36+
if [ -n "$failed" ]; then
37+
echo "FAILED:$failed" >&2
38+
exit 1
39+
fi
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package com.adaworldapi.interop.tinkerpop;
2+
3+
import com.adaworldapi.graph.Graph;
4+
import com.adaworldapi.lancegraph.RowStore;
5+
6+
import java.util.ArrayList;
7+
import java.util.Arrays;
8+
import java.util.List;
9+
10+
import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
11+
import org.apache.tinkerpop.gremlin.process.traversal.Traverser;
12+
import org.apache.tinkerpop.gremlin.process.traversal.step.util.AbstractStep;
13+
import org.apache.tinkerpop.gremlin.process.traversal.util.FastNoSuchElementException;
14+
15+
/**
16+
* The step {@link LanceHopStrategy} puts in place of {@code V(ids).out(l)^k.dedup().count()}.
17+
* It emits one value: the number of distinct rows the mask hop chain reaches from the seeds.
18+
*
19+
* <p>The answer is computed when the traversal is first iterated, not when the strategy runs,
20+
* so building a traversal costs nothing until it is used.
21+
*/
22+
final class LanceHopCountStep<S> extends AbstractStep<S, Long> {
23+
24+
private final RowStore store;
25+
private final long[] seeds;
26+
private final int[] hops;
27+
private boolean done;
28+
29+
LanceHopCountStep(Traversal.Admin<?, ?> traversal, RowStore store, long[] seeds, int[] hops) {
30+
super(traversal);
31+
this.store = store;
32+
this.seeds = seeds.clone();
33+
this.hops = hops.clone();
34+
}
35+
36+
@Override
37+
protected Traverser.Admin<Long> processNextStart() {
38+
if (done) {
39+
throw FastNoSuchElementException.instance();
40+
}
41+
done = true;
42+
// The generator is typed by the step the traverser starts at; this step starts the
43+
// lowered traversal and produces the Long it carries.
44+
@SuppressWarnings({"rawtypes", "unchecked"})
45+
Traverser.Admin<Long> t = getTraversal().getTraverserGenerator()
46+
.generate(count(), (org.apache.tinkerpop.gremlin.process.traversal.Step) this, 1L);
47+
return t;
48+
}
49+
50+
private long count() {
51+
List<Graph> chain = new ArrayList<>();
52+
try {
53+
Graph g = Graph.open(store);
54+
chain.add(g);
55+
g = g.from(seeds);
56+
chain.add(g);
57+
for (int classid : hops) {
58+
g = g.hop(classid);
59+
chain.add(g);
60+
}
61+
return g.count();
62+
} finally {
63+
for (Graph g : chain) {
64+
g.close();
65+
}
66+
}
67+
}
68+
69+
@Override
70+
public void reset() {
71+
super.reset();
72+
done = false;
73+
}
74+
75+
@Override
76+
public String toString() {
77+
return "LanceHopCountStep(seeds=" + seeds.length + ", hops=" + Arrays.toString(hops) + ")";
78+
}
79+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package com.adaworldapi.interop.tinkerpop;
2+
3+
import com.adaworldapi.lancegraph.RowStore;
4+
5+
import java.util.ArrayList;
6+
import java.util.List;
7+
import java.util.Map;
8+
import java.util.concurrent.atomic.AtomicLong;
9+
10+
import org.apache.tinkerpop.gremlin.process.traversal.Step;
11+
import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
12+
import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy.ProviderOptimizationStrategy;
13+
import org.apache.tinkerpop.gremlin.process.traversal.step.HasContainerHolder;
14+
import org.apache.tinkerpop.gremlin.process.traversal.step.filter.DedupGlobalStep;
15+
import org.apache.tinkerpop.gremlin.process.traversal.step.map.CountGlobalStep;
16+
import org.apache.tinkerpop.gremlin.process.traversal.step.map.GraphStep;
17+
import org.apache.tinkerpop.gremlin.process.traversal.step.map.VertexStep;
18+
import org.apache.tinkerpop.gremlin.process.traversal.step.util.EmptyStep;
19+
import org.apache.tinkerpop.gremlin.process.traversal.step.map.NoOpBarrierStep;
20+
import org.apache.tinkerpop.gremlin.process.traversal.strategy.AbstractTraversalStrategy;
21+
import org.apache.tinkerpop.gremlin.process.traversal.util.TraversalHelper;
22+
import org.apache.tinkerpop.gremlin.structure.Direction;
23+
import org.apache.tinkerpop.gremlin.structure.Vertex;
24+
25+
/**
26+
* Quick win A2: a TinkerPop provider strategy that lowers one traversal shape onto the
27+
* lance-graph-java mask hop chain, and leaves every other traversal to Gremlin.
28+
*
29+
* <p>The lowered shape is exactly
30+
*
31+
* <pre>{@code g.V(ids).out(label)^k.dedup().count()}</pre>
32+
*
33+
* with k &gt;= 1, one mapped edge label per hop, and no {@code has()} folded into the start
34+
* step. {@code repeat(out(label)).times(k)} reaches the same shape, because TinkerPop's
35+
* {@code RepeatUnrollStrategy} unrolls it before provider strategies run. A1 proved the
36+
* {@code dedup()} form equal to the mask chain; the form without {@code dedup()} counts walks,
37+
* not vertices, and is never lowered.
38+
*
39+
* <p><strong>Every decision is counted.</strong> {@link #lowered()} counts traversals replaced
40+
* by a {@link LanceHopCountStep}; {@link #fellBack()} counts root traversals left to Gremlin,
41+
* with the first reason kept in {@link #lastFallbackReason()}. A fallback is never an error:
42+
* Gremlin still answers, so a caller only loses speed, never correctness.
43+
*
44+
* <p>The strategy reads the row store it was given; the graph the traversal source wraps is not
45+
* consulted for a lowered traversal. Keeping the two consistent is the caller's job (in the
46+
* harness both are built from the same fixture). Vertex ids are row numbers.
47+
*/
48+
public final class LanceHopStrategy
49+
extends AbstractTraversalStrategy<ProviderOptimizationStrategy>
50+
implements ProviderOptimizationStrategy {
51+
52+
private final RowStore store;
53+
private final Map<String, Integer> edgeClassids;
54+
private final AtomicLong lowered = new AtomicLong();
55+
private final AtomicLong fellBack = new AtomicLong();
56+
private volatile String lastFallbackReason = "";
57+
58+
/**
59+
* @param store the row store lowered traversals are answered from
60+
* @param edgeClassids Gremlin edge label to lance-graph edge classid; a label not in the map
61+
* is not lowered
62+
*/
63+
public LanceHopStrategy(RowStore store, Map<String, Integer> edgeClassids) {
64+
this.store = store;
65+
this.edgeClassids = Map.copyOf(edgeClassids);
66+
}
67+
68+
/** Number of traversals answered by the mask hop chain. */
69+
public long lowered() {
70+
return lowered.get();
71+
}
72+
73+
/** Number of root traversals left to Gremlin. */
74+
public long fellBack() {
75+
return fellBack.get();
76+
}
77+
78+
/** Why the most recent fallback happened; empty if none has. */
79+
public String lastFallbackReason() {
80+
return lastFallbackReason;
81+
}
82+
83+
@Override
84+
public void apply(Traversal.Admin<?, ?> traversal) {
85+
// Child traversals (inside by(), repeat() bodies, ...) are not candidates; only the
86+
// root is, and only the root is counted, so one query counts once.
87+
if (!(traversal.getParent() instanceof EmptyStep)) {
88+
return;
89+
}
90+
String reason = plan(traversal);
91+
if (reason != null) {
92+
fellBack.incrementAndGet();
93+
lastFallbackReason = reason;
94+
}
95+
}
96+
97+
/** Lowers the traversal and returns null, or leaves it untouched and returns the reason. */
98+
@SuppressWarnings({"rawtypes", "unchecked"})
99+
private String plan(Traversal.Admin<?, ?> traversal) {
100+
List<Step> steps = new ArrayList<>();
101+
for (Step s : traversal.getSteps()) {
102+
if (!(s instanceof NoOpBarrierStep)) {
103+
steps.add(s);
104+
}
105+
}
106+
if (steps.size() < 4) {
107+
return "too few steps for V().out()..dedup().count()";
108+
}
109+
if (!(steps.get(0) instanceof GraphStep<?, ?> start) || !start.returnsVertex()) {
110+
return "does not start with V()";
111+
}
112+
if (start instanceof HasContainerHolder holder && !holder.getHasContainers().isEmpty()) {
113+
return "has() on the start step";
114+
}
115+
long[] seeds = seedRows(start.getIds());
116+
if (seeds == null) {
117+
return "V() needs explicit numeric row ids within the row store";
118+
}
119+
int last = steps.size() - 1;
120+
if (!(steps.get(last) instanceof CountGlobalStep)) {
121+
return "does not end with count()";
122+
}
123+
if (!(steps.get(last - 1) instanceof DedupGlobalStep<?> dedup)
124+
|| !dedup.getLocalChildren().isEmpty()
125+
|| !dedup.getScopeKeys().isEmpty()) {
126+
return "count() without a plain dedup() counts walks, not vertices";
127+
}
128+
int[] hops = new int[last - 2];
129+
for (int i = 1; i < last - 1; i++) {
130+
// Provider strategies run in no fixed order, so has() can still be its own step
131+
// here rather than folded into the start step.
132+
if (steps.get(i) instanceof HasContainerHolder) {
133+
return "has() at step " + i;
134+
}
135+
if (!(steps.get(i) instanceof VertexStep<?> v)) {
136+
return "step " + i + " is not out(label): " + steps.get(i);
137+
}
138+
if (v.getDirection() != Direction.OUT || !v.returnsVertex()) {
139+
return "step " + i + " is not an out() to vertices";
140+
}
141+
if (v.getEdgeLabels().length != 1) {
142+
return "step " + i + " needs exactly one edge label";
143+
}
144+
Integer classid = edgeClassids.get(v.getEdgeLabels()[0]);
145+
if (classid == null) {
146+
return "edge label '" + v.getEdgeLabels()[0] + "' has no lance-graph classid";
147+
}
148+
hops[i - 1] = classid;
149+
}
150+
if (!steps.get(0).getLabels().isEmpty() || anyLabelled(steps)) {
151+
return "as() labels would be lost";
152+
}
153+
TraversalHelper.removeAllSteps(traversal);
154+
traversal.addStep(new LanceHopCountStep(traversal, store, seeds, hops));
155+
lowered.incrementAndGet();
156+
return null;
157+
}
158+
159+
@SuppressWarnings("rawtypes")
160+
private static boolean anyLabelled(List<Step> steps) {
161+
for (Step s : steps) {
162+
if (!s.getLabels().isEmpty()) {
163+
return true;
164+
}
165+
}
166+
return false;
167+
}
168+
169+
/** Row numbers for V(ids), or null when any id is not a row of this store. */
170+
private long[] seedRows(Object[] ids) {
171+
if (ids == null || ids.length == 0) {
172+
return null;
173+
}
174+
long rows = store.rowCount();
175+
long[] out = new long[ids.length];
176+
for (int i = 0; i < ids.length; i++) {
177+
Object id = ids[i] instanceof Vertex v ? v.id() : ids[i];
178+
if (!(id instanceof Long || id instanceof Integer || id instanceof Short)) {
179+
return null;
180+
}
181+
long row = ((Number) id).longValue();
182+
if (row < 0 || row >= rows) {
183+
return null;
184+
}
185+
out[i] = row;
186+
}
187+
return out;
188+
}
189+
}

0 commit comments

Comments
 (0)