From 673f58e9c712849d3dd8212ef2cddefa2f2b46af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 14:23:16 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01AhMhXd2gDhYzTgwKeHaWHB --- .claude/board/LATEST_STATE.md | 15 ++ .claude/board/PR_ARC_INVENTORY.md | 21 +- interop/tinkerpop/README.md | 46 ++++- interop/tinkerpop/run.sh | 12 +- .../interop/tinkerpop/LanceHopCountStep.java | 79 ++++++++ .../interop/tinkerpop/LanceHopStrategy.java | 189 ++++++++++++++++++ .../tinkerpop/LoweringStrategyTest.java | 154 ++++++++++++++ .../interop/tinkerpop/TinkerParityTest.java | 4 +- 8 files changed, 513 insertions(+), 7 deletions(-) create mode 100644 interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopCountStep.java create mode 100644 interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopStrategy.java create mode 100644 interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LoweringStrategyTest.java diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 8c6a638..b75eecf 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,18 @@ +## 2026-10-09 — Gremlin traversals lowered onto the mask hop chain (interop/tinkerpop, A2) + +`LanceHopStrategy` is a TinkerPop provider strategy. It rewrites +`V(ids).out(label)^k.dedup().count()` into one `LanceHopCountStep` answered by the +lance-graph-java mask hop chain, and leaves everything else to Gremlin with a +counted fallback and a reason. + +- **Measured:** 55 checks green. Lowered results equal plain Gremlin for k = 1..3, + through both `out()^k` and `repeat().times(k)`. Against an empty TinkerGraph the + lowered traversal still returns the row store's count, which proves where the + answer comes from. +- **Found:** Gremlin turns `out().count()` into `outE().count()` before provider + strategies run, so a walk count needs `barrier()` to reach the `dedup()` guard. + That case is now a test, and removing the guard turns it red. + ## 2026-10-09 — Gremlin and the mask hop chain agree (interop/tinkerpop, A1) `interop/tinkerpop/` adds a parity harness, built outside `consumers/` so the diff --git a/.claude/board/PR_ARC_INVENTORY.md b/.claude/board/PR_ARC_INVENTORY.md index 0e2bb87..9f67462 100644 --- a/.claude/board/PR_ARC_INVENTORY.md +++ b/.claude/board/PR_ARC_INVENTORY.md @@ -8,10 +8,29 @@ > anti-pattern the imported board rules name. Backfilled below in one > pass rather than left stale; PR #4 onward gets its entry at merge time. -## PR (open) — A1 TinkerPop parity harness (branch `ccr-70e679fa-9qvl9y`) +## PR (open) — A2 TinkerPop lowering strategy (branch `ccr-70e679fa-9qvl9y`) **Entry written at open.** +- **Added:** `interop/tinkerpop/LanceHopStrategy` (a TinkerPop + `ProviderOptimizationStrategy`), `LanceHopCountStep`, `LoweringStrategyTest`; + `run.sh` now runs both mains and fails if either fails. +- **Locked:** only `V(ids).out(label)^k.dedup().count()` is lowered (k >= 1, one + mapped label per hop, numeric row ids within the store, no `has()` or `as()`). + Every other root traversal is counted as a fallback with a reason and answers + exactly as Gremlin. +- **Gates:** 55 checks green (plus A1's 25). Disable runs: the `dedup()` guard + removed turns 6 checks red (a walk count through `barrier()` gets lowered to a + distinct count); a lowered step answering 0 turns 7 red. +- **Deferred:** A3 (measure lowered vs plain Gremlin), A5 (Gremlin Server + endpoint). +- **Confidence:** high for correctness of the lowered shape on this fixture. + Nothing yet about speed. + +## PR #89 — A1 TinkerPop parity harness (merged `0136005`, branch `ccr-70e679fa-9qvl9y`) + +**Entry written at open; merge recorded 2026-10-09.** + - **Added:** `interop/tinkerpop/`, consisting of `TinkerParityTest`, `deps.lock` (19 jars, sha256), `fetch-deps.sh`, `run.sh`, a README, and gitignore lines for `lib/` and `out/`. diff --git a/interop/tinkerpop/README.md b/interop/tinkerpop/README.md index c5bd3f1..0649389 100644 --- a/interop/tinkerpop/README.md +++ b/interop/tinkerpop/README.md @@ -1,4 +1,4 @@ -# interop/tinkerpop: A1 parity harness +# interop/tinkerpop: A1 parity harness and A2 lowering strategy One seeded graph, two engines, the same answers. The harness opens a `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: ## Rules it follows - 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. -- 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. +- Nothing here changes `src/main`. + +## A2: `LanceHopStrategy` (55 checks, all green) + +A TinkerPop provider strategy. Register it on a traversal source: + +```java +GraphTraversalSource g = graph.traversal() + .withStrategies(new LanceHopStrategy(store, Map.of("knows", Edge.KNOWS))); +long n = g.V(seeds).out("knows").out("knows").dedup().count().next(); +``` + +It lowers exactly one shape, `V(ids).out(label)^k.dedup().count()` with k >= 1, onto +the mask hop chain. The whole traversal becomes one `LanceHopCountStep`, which +emits the count when iterated. `repeat(out(label)).times(k)` reaches the same +shape because TinkerPop unrolls it first. Vertex ids are row numbers, and each +edge label maps to an edge classid. + +Everything else stays with Gremlin, and each such root traversal is counted in +`fellBack()`, with the reason in `lastFallbackReason()`: + +- a walk count (no `dedup()`), which is a bag, not a set; +- `has()`, `in()`, `both()`, an unmapped label, more than one label per hop; +- `V()` without ids, a non-numeric id, or an id outside the row store; +- any `as()` label. + +`LoweringStrategyTest` checks four things: + +- **Parity:** lowered equals plain Gremlin for k = 1..3, through both `out()^k` and `repeat()`, and the traversal really ran as one `LanceHopCountStep`. +- **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. +- **Fallbacks:** each excluded shape is counted once and answers exactly as Gremlin. +- **Counters:** the lowered and fallback counters move by one per root traversal. + +Disable runs, each red then green: +- remove the `dedup()` guard: the walk count through `barrier()` gets lowered and gives the distinct count instead (6 red); +- make the lowered step return 0: every parity check and the provenance check go red (7 red). + +Gremlin rewrites `out().count()` into `outE().count()` before provider strategies +run, so a plain walk count never reaches the `dedup()` guard. The `barrier()` +case exists to exercise that guard. + +The strategy reads the row store it was given, not the graph the traversal +source wraps. Keeping both consistent is the caller's job. diff --git a/interop/tinkerpop/run.sh b/interop/tinkerpop/run.sh index 60d415e..2088e6c 100755 --- a/interop/tinkerpop/run.sh +++ b/interop/tinkerpop/run.sh @@ -27,5 +27,13 @@ if ! "$J/javac" --release 28 --enable-preview -d "$out" -cp "$cp" \ echo "compile failed" >&2 exit 1 fi -exec "$J/java" --enable-preview --enable-native-access=ALL-UNNAMED -Dlgj.library="$SO" \ - -cp "$out:$cp" com.adaworldapi.interop.tinkerpop.TinkerParityTest +# Run every main, then fail if any failed: one red suite must not hide the next. +failed="" +for main in TinkerParityTest LoweringStrategyTest; do + "$J/java" --enable-preview --enable-native-access=ALL-UNNAMED -Dlgj.library="$SO" \ + -cp "$out:$cp" "com.adaworldapi.interop.tinkerpop.$main" || failed="$failed $main" +done +if [ -n "$failed" ]; then + echo "FAILED:$failed" >&2 + exit 1 +fi diff --git a/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopCountStep.java b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopCountStep.java new file mode 100644 index 0000000..42fbf32 --- /dev/null +++ b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopCountStep.java @@ -0,0 +1,79 @@ +package com.adaworldapi.interop.tinkerpop; + +import com.adaworldapi.graph.Graph; +import com.adaworldapi.lancegraph.RowStore; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.apache.tinkerpop.gremlin.process.traversal.Traversal; +import org.apache.tinkerpop.gremlin.process.traversal.Traverser; +import org.apache.tinkerpop.gremlin.process.traversal.step.util.AbstractStep; +import org.apache.tinkerpop.gremlin.process.traversal.util.FastNoSuchElementException; + +/** + * The step {@link LanceHopStrategy} puts in place of {@code V(ids).out(l)^k.dedup().count()}. + * It emits one value: the number of distinct rows the mask hop chain reaches from the seeds. + * + *

The answer is computed when the traversal is first iterated, not when the strategy runs, + * so building a traversal costs nothing until it is used. + */ +final class LanceHopCountStep extends AbstractStep { + + private final RowStore store; + private final long[] seeds; + private final int[] hops; + private boolean done; + + LanceHopCountStep(Traversal.Admin traversal, RowStore store, long[] seeds, int[] hops) { + super(traversal); + this.store = store; + this.seeds = seeds.clone(); + this.hops = hops.clone(); + } + + @Override + protected Traverser.Admin processNextStart() { + if (done) { + throw FastNoSuchElementException.instance(); + } + done = true; + // The generator is typed by the step the traverser starts at; this step starts the + // lowered traversal and produces the Long it carries. + @SuppressWarnings({"rawtypes", "unchecked"}) + Traverser.Admin t = getTraversal().getTraverserGenerator() + .generate(count(), (org.apache.tinkerpop.gremlin.process.traversal.Step) this, 1L); + return t; + } + + private long count() { + List chain = new ArrayList<>(); + try { + Graph g = Graph.open(store); + chain.add(g); + g = g.from(seeds); + chain.add(g); + for (int classid : hops) { + g = g.hop(classid); + chain.add(g); + } + return g.count(); + } finally { + for (Graph g : chain) { + g.close(); + } + } + } + + @Override + public void reset() { + super.reset(); + done = false; + } + + @Override + public String toString() { + return "LanceHopCountStep(seeds=" + seeds.length + ", hops=" + Arrays.toString(hops) + ")"; + } +} diff --git a/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopStrategy.java b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopStrategy.java new file mode 100644 index 0000000..d9387d6 --- /dev/null +++ b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LanceHopStrategy.java @@ -0,0 +1,189 @@ +package com.adaworldapi.interop.tinkerpop; + +import com.adaworldapi.lancegraph.RowStore; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +import org.apache.tinkerpop.gremlin.process.traversal.Step; +import org.apache.tinkerpop.gremlin.process.traversal.Traversal; +import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy.ProviderOptimizationStrategy; +import org.apache.tinkerpop.gremlin.process.traversal.step.HasContainerHolder; +import org.apache.tinkerpop.gremlin.process.traversal.step.filter.DedupGlobalStep; +import org.apache.tinkerpop.gremlin.process.traversal.step.map.CountGlobalStep; +import org.apache.tinkerpop.gremlin.process.traversal.step.map.GraphStep; +import org.apache.tinkerpop.gremlin.process.traversal.step.map.VertexStep; +import org.apache.tinkerpop.gremlin.process.traversal.step.util.EmptyStep; +import org.apache.tinkerpop.gremlin.process.traversal.step.map.NoOpBarrierStep; +import org.apache.tinkerpop.gremlin.process.traversal.strategy.AbstractTraversalStrategy; +import org.apache.tinkerpop.gremlin.process.traversal.util.TraversalHelper; +import org.apache.tinkerpop.gremlin.structure.Direction; +import org.apache.tinkerpop.gremlin.structure.Vertex; + +/** + * Quick win A2: a TinkerPop provider strategy that lowers one traversal shape onto the + * lance-graph-java mask hop chain, and leaves every other traversal to Gremlin. + * + *

The lowered shape is exactly + * + *

{@code g.V(ids).out(label)^k.dedup().count()}
+ * + * with k >= 1, one mapped edge label per hop, and no {@code has()} folded into the start + * step. {@code repeat(out(label)).times(k)} reaches the same shape, because TinkerPop's + * {@code RepeatUnrollStrategy} unrolls it before provider strategies run. A1 proved the + * {@code dedup()} form equal to the mask chain; the form without {@code dedup()} counts walks, + * not vertices, and is never lowered. + * + *

Every decision is counted. {@link #lowered()} counts traversals replaced + * by a {@link LanceHopCountStep}; {@link #fellBack()} counts root traversals left to Gremlin, + * with the first reason kept in {@link #lastFallbackReason()}. A fallback is never an error: + * Gremlin still answers, so a caller only loses speed, never correctness. + * + *

The strategy reads the row store it was given; the graph the traversal source wraps is not + * consulted for a lowered traversal. Keeping the two consistent is the caller's job (in the + * harness both are built from the same fixture). Vertex ids are row numbers. + */ +public final class LanceHopStrategy + extends AbstractTraversalStrategy + implements ProviderOptimizationStrategy { + + private final RowStore store; + private final Map edgeClassids; + private final AtomicLong lowered = new AtomicLong(); + private final AtomicLong fellBack = new AtomicLong(); + private volatile String lastFallbackReason = ""; + + /** + * @param store the row store lowered traversals are answered from + * @param edgeClassids Gremlin edge label to lance-graph edge classid; a label not in the map + * is not lowered + */ + public LanceHopStrategy(RowStore store, Map edgeClassids) { + this.store = store; + this.edgeClassids = Map.copyOf(edgeClassids); + } + + /** Number of traversals answered by the mask hop chain. */ + public long lowered() { + return lowered.get(); + } + + /** Number of root traversals left to Gremlin. */ + public long fellBack() { + return fellBack.get(); + } + + /** Why the most recent fallback happened; empty if none has. */ + public String lastFallbackReason() { + return lastFallbackReason; + } + + @Override + public void apply(Traversal.Admin traversal) { + // Child traversals (inside by(), repeat() bodies, ...) are not candidates; only the + // root is, and only the root is counted, so one query counts once. + if (!(traversal.getParent() instanceof EmptyStep)) { + return; + } + String reason = plan(traversal); + if (reason != null) { + fellBack.incrementAndGet(); + lastFallbackReason = reason; + } + } + + /** Lowers the traversal and returns null, or leaves it untouched and returns the reason. */ + @SuppressWarnings({"rawtypes", "unchecked"}) + private String plan(Traversal.Admin traversal) { + List steps = new ArrayList<>(); + for (Step s : traversal.getSteps()) { + if (!(s instanceof NoOpBarrierStep)) { + steps.add(s); + } + } + if (steps.size() < 4) { + return "too few steps for V().out()..dedup().count()"; + } + if (!(steps.get(0) instanceof GraphStep start) || !start.returnsVertex()) { + return "does not start with V()"; + } + if (start instanceof HasContainerHolder holder && !holder.getHasContainers().isEmpty()) { + return "has() on the start step"; + } + long[] seeds = seedRows(start.getIds()); + if (seeds == null) { + return "V() needs explicit numeric row ids within the row store"; + } + int last = steps.size() - 1; + if (!(steps.get(last) instanceof CountGlobalStep)) { + return "does not end with count()"; + } + if (!(steps.get(last - 1) instanceof DedupGlobalStep dedup) + || !dedup.getLocalChildren().isEmpty() + || !dedup.getScopeKeys().isEmpty()) { + return "count() without a plain dedup() counts walks, not vertices"; + } + int[] hops = new int[last - 2]; + for (int i = 1; i < last - 1; i++) { + // Provider strategies run in no fixed order, so has() can still be its own step + // here rather than folded into the start step. + if (steps.get(i) instanceof HasContainerHolder) { + return "has() at step " + i; + } + if (!(steps.get(i) instanceof VertexStep v)) { + return "step " + i + " is not out(label): " + steps.get(i); + } + if (v.getDirection() != Direction.OUT || !v.returnsVertex()) { + return "step " + i + " is not an out() to vertices"; + } + if (v.getEdgeLabels().length != 1) { + return "step " + i + " needs exactly one edge label"; + } + Integer classid = edgeClassids.get(v.getEdgeLabels()[0]); + if (classid == null) { + return "edge label '" + v.getEdgeLabels()[0] + "' has no lance-graph classid"; + } + hops[i - 1] = classid; + } + if (!steps.get(0).getLabels().isEmpty() || anyLabelled(steps)) { + return "as() labels would be lost"; + } + TraversalHelper.removeAllSteps(traversal); + traversal.addStep(new LanceHopCountStep(traversal, store, seeds, hops)); + lowered.incrementAndGet(); + return null; + } + + @SuppressWarnings("rawtypes") + private static boolean anyLabelled(List steps) { + for (Step s : steps) { + if (!s.getLabels().isEmpty()) { + return true; + } + } + return false; + } + + /** Row numbers for V(ids), or null when any id is not a row of this store. */ + private long[] seedRows(Object[] ids) { + if (ids == null || ids.length == 0) { + return null; + } + long rows = store.rowCount(); + long[] out = new long[ids.length]; + for (int i = 0; i < ids.length; i++) { + Object id = ids[i] instanceof Vertex v ? v.id() : ids[i]; + if (!(id instanceof Long || id instanceof Integer || id instanceof Short)) { + return null; + } + long row = ((Number) id).longValue(); + if (row < 0 || row >= rows) { + return null; + } + out[i] = row; + } + return out; + } +} diff --git a/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LoweringStrategyTest.java b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LoweringStrategyTest.java new file mode 100644 index 0000000..bf0a3b6 --- /dev/null +++ b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/LoweringStrategyTest.java @@ -0,0 +1,154 @@ +package com.adaworldapi.interop.tinkerpop; + +import com.adaworldapi.graph.Edge; +import com.adaworldapi.lancegraph.Checks; +import com.adaworldapi.lancegraph.NativeRuntime; +import com.adaworldapi.lancegraph.RowStore; + +import java.util.Map; +import java.util.function.Function; + +import org.apache.tinkerpop.gremlin.process.traversal.Traversal; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversal; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.GraphTraversalSource; +import org.apache.tinkerpop.gremlin.process.traversal.dsl.graph.__; +import org.apache.tinkerpop.gremlin.structure.Vertex; +import org.apache.tinkerpop.gremlin.tinkergraph.structure.TinkerGraph; + +/** + * Quick win A2: {@link LanceHopStrategy} lowers {@code V(ids).out(l)^k.dedup().count()} onto the + * mask hop chain and leaves everything else to Gremlin. + * + *

Four groups of checks: + * + *

    + *
  1. Parity. With the strategy, the answer equals plain Gremlin's for k = 1..3 on the + * A1 fixtures, through both {@code out()^k} and {@code repeat(out()).times(k)}, and the + * traversal really ran as one {@link LanceHopCountStep}. + *
  2. Provenance. Run against an EMPTY TinkerGraph, the lowered traversal still returns + * the row store's count. A strategy that only matched the shape and let Gremlin answer + * would return 0 here. + *
  3. Fallback stays silent. Shapes it must not lower (walk counts without + * {@code dedup()}, {@code has()}, {@code in()}, an unmapped label, {@code V()} without ids, + * an id outside the store) are counted as fallbacks and still answer exactly as Gremlin. + *
  4. Counting. The lowered and fallback counters move by exactly one per root + * traversal. + *
+ */ +public final class LoweringStrategyTest { + + private LoweringStrategyTest() {} + + private static final String KNOWS = "knows"; + private static final long[] SEEDS = {5, 42, 79, 116, 153, 190, 227, 264, 301, 338}; + + public static void main(String[] args) { + System.out.println("LoweringStrategyTest"); + if (!NativeRuntime.isAvailable()) { + System.exit(Checks.reportUnavailable("LoweringStrategyTest")); + } + Checks c = new Checks("LoweringStrategyTest"); + try (RowStore store = RowStore.openWithEdges(2000L, 0xF00D_CAFEL, Edge.KNOWS, 0x0L, 25)) { + TinkerGraph tg = TinkerParityTest.copyIntoTinkerGraph(store); + parity(c, store, tg); + provenance(c, store); + fallbacks(c, store, tg); + tg.close(); + } catch (Exception e) { + throw new IllegalStateException(e); + } + System.exit(c.report()); + } + + private static LanceHopStrategy strategy(RowStore store) { + return new LanceHopStrategy(store, Map.of(KNOWS, Edge.KNOWS)); + } + + private static GraphTraversal outN(GraphTraversal t, int k) { + for (int i = 0; i < k; i++) { + t = t.out(KNOWS); + } + return t; + } + + private static boolean ranAsOneLoweredStep(Traversal.Admin t) { + return t.getSteps().size() == 1 && t.getSteps().get(0) instanceof LanceHopCountStep; + } + + private static void parity(Checks c, RowStore store, TinkerGraph tg) { + c.section("parity with plain Gremlin"); + GraphTraversalSource plain = tg.traversal(); + Object[] ids = TinkerParityTest.boxed(SEEDS); + for (int k = 1; k <= 3; k++) { + long expected = outN(plain.V(ids), k).dedup().count().next(); + + LanceHopStrategy s = strategy(store); + GraphTraversalSource g = tg.traversal().withStrategies(s); + Traversal.Admin t = outN(g.V(ids), k).dedup().count().asAdmin(); + t.applyStrategies(); + c.that("k=" + k + ": out()^k runs as one LanceHopCountStep (got " + t.getSteps() + ")", + ranAsOneLoweredStep(t)); + c.eq("k=" + k + ": lowered out()^k equals Gremlin", expected, (long) t.next()); + c.eq("k=" + k + ": counted as lowered once", 1L, s.lowered()); + c.eq("k=" + k + ": no fallback", 0L, s.fellBack()); + + LanceHopStrategy r = strategy(store); + long viaRepeat = tg.traversal().withStrategies(r) + .V(ids).repeat(__.out(KNOWS)).times(k).dedup().count().next(); + c.eq("k=" + k + ": lowered repeat(out()).times(k) equals Gremlin", expected, viaRepeat); + c.eq("k=" + k + ": repeat form also lowered", 1L, r.lowered()); + } + } + + private static void provenance(Checks c, RowStore store) { + c.section("provenance: the answer comes from the row store"); + TinkerGraph empty = TinkerGraph.open(); + Object[] ids = TinkerParityTest.boxed(SEEDS); + long plain = empty.traversal().V(ids).out(KNOWS).out(KNOWS).dedup().count().next(); + long lowered = empty.traversal().withStrategies(strategy(store)) + .V(ids).out(KNOWS).out(KNOWS).dedup().count().next(); + c.eq("plain Gremlin over an empty graph counts nothing", 0L, plain); + c.that("lowered traversal over the same empty graph still answers from the row store (" + + lowered + ")", lowered > 0); + empty.close(); + } + + private static void fallbacks(Checks c, RowStore store, TinkerGraph tg) { + c.section("fallbacks leave the answer to Gremlin"); + Object[] ids = TinkerParityTest.boxed(SEEDS); + fallback(c, store, tg, "walk count without dedup()", + g -> g.V(ids).out(KNOWS).out(KNOWS).count(), "walks, not vertices"); + // Gremlin rewrites out().count() into outE().count() before provider strategies run, + // so the case above never reaches the dedup check. With a barrier() in between, the + // out() steps survive and only the dedup check stands between this walk count and a + // wrong (distinct) answer. + fallback(c, store, tg, "walk count through barrier()", + g -> g.V(ids).out(KNOWS).out(KNOWS).barrier().count(), "walks, not vertices"); + fallback(c, store, tg, "has() on the start step", + g -> g.V(ids).has("x", 1).out(KNOWS).dedup().count(), "has()"); + fallback(c, store, tg, "in() instead of out()", + g -> g.V(ids).in(KNOWS).dedup().count(), "not an out()"); + fallback(c, store, tg, "unmapped edge label", + g -> g.V(ids).out("likes").dedup().count(), "no lance-graph classid"); + fallback(c, store, tg, "V() without ids", + g -> g.V().out(KNOWS).dedup().count(), "explicit numeric row ids"); + fallback(c, store, tg, "id outside the row store", + g -> g.V(5L, 999_999L).out(KNOWS).dedup().count(), "explicit numeric row ids"); + } + + private static void fallback(Checks c, RowStore store, TinkerGraph tg, String what, + Function> query, String reasonPart) { + long expected = query.apply(tg.traversal()).next(); + LanceHopStrategy s = strategy(store); + Traversal.Admin t = query.apply(tg.traversal().withStrategies(s)).asAdmin(); + t.applyStrategies(); + boolean lowered = t.getSteps().stream().anyMatch(st -> st instanceof LanceHopCountStep); + long got = t.next(); + c.that(what + ": not lowered", !lowered); + c.eq(what + ": counted as one fallback", 1L, s.fellBack()); + c.eq(what + ": lowered counter untouched", 0L, s.lowered()); + c.that(what + ": reason names the cause ('" + s.lastFallbackReason() + "')", + s.lastFallbackReason().contains(reasonPart)); + c.eq(what + ": Gremlin's answer is unchanged", expected, got); + } +} diff --git a/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/TinkerParityTest.java b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/TinkerParityTest.java index b3fcdaf..db453fe 100644 --- a/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/TinkerParityTest.java +++ b/interop/tinkerpop/src/com/adaworldapi/interop/tinkerpop/TinkerParityTest.java @@ -146,7 +146,7 @@ private static long lanceHopCount(RowStore store, long[] seeds, int edgeClassid, } /** Test-oracle copy: every KNOWS facet becomes one Gremlin edge, duplicates kept. */ - private static TinkerGraph copyIntoTinkerGraph(RowStore store) { + static TinkerGraph copyIntoTinkerGraph(RowStore store) { TinkerGraph tg = TinkerGraph.open(); long n = store.rowCount(); Vertex[] vs = new Vertex[(int) n]; @@ -171,7 +171,7 @@ private static TinkerGraph copyIntoTinkerGraph(RowStore store) { return tg; } - private static Object[] boxed(long[] rows) { + static Object[] boxed(long[] rows) { Object[] out = new Object[rows.length]; for (int i = 0; i < rows.length; i++) { out[i] = rows[i];