From 4a21b415f460733b5e0f8ee2ea0e4bbcd271b961 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Fri, 19 Jun 2026 10:41:21 -0400 Subject: [PATCH 1/6] [FEATURE] Provide a possibility to store and load an empty graph Signed-off-by: Andriy Redko --- .../release notes/4.0.0-RC.9/pr685.feature.md | 8 +++++ .../jvector/graph/GraphIndexBuilder.java | 8 +++-- .../jvector/graph/ImmutableGraphIndex.java | 4 ++- .../jvector/graph/OnHeapGraphIndex.java | 13 ++++++--- .../graph/disk/AbstractGraphIndexWriter.java | 4 +-- .../jvector/graph/disk/CommonHeader.java | 4 +-- .../jvector/graph/disk/OnDiskGraphIndex.java | 16 ++++++---- .../jvector/graph/GraphIndexBuilderTest.java | 29 +++++++++++++++++++ .../jvector/graph/MockVectorValues.java | 4 +++ 9 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 docs/release notes/4.0.0-RC.9/pr685.feature.md diff --git a/docs/release notes/4.0.0-RC.9/pr685.feature.md b/docs/release notes/4.0.0-RC.9/pr685.feature.md new file mode 100644 index 000000000..806e19c9a --- /dev/null +++ b/docs/release notes/4.0.0-RC.9/pr685.feature.md @@ -0,0 +1,8 @@ +### Provide a possibility to store and load an empty graph + +**Description** +Provide a possibility to store and load an empty graph from/to disk + +**Purpose / Impact** +- Helps to preserve graph metadata (similarity function, features, dimensions, even if the graph is empty) + diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java index 8135bba25..d4432665e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java @@ -951,7 +951,9 @@ private void loadV4(RandomAccessReader in) throws IOException { } graph.setDegrees(layerDegrees); - graph.updateEntryNode(new NodeAtLevel(graph.getMaxLevel(), entryNode)); + if (entryNode != ImmutableGraphIndex.OMITTED) { + graph.updateEntryNode(new NodeAtLevel(graph.getMaxLevel(), entryNode)); + } } @Deprecated @@ -984,7 +986,9 @@ private void loadV3(RandomAccessReader in, int size) throws IOException { graph.markComplete(new NodeAtLevel(0, nodeId)); } - graph.updateEntryNode(new NodeAtLevel(0, entryNode)); + if (entryNode != ImmutableGraphIndex.OMITTED) { + graph.updateEntryNode(new NodeAtLevel(0, entryNode)); + } graph.setDegrees(List.of(maxDegree)); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java index a4758c493..4bee5f73d 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java @@ -24,6 +24,7 @@ package io.github.jbellis.jvector.graph; +import io.github.jbellis.jvector.graph.disk.OrdinalMapper; import io.github.jbellis.jvector.graph.similarity.ScoreFunction; import io.github.jbellis.jvector.util.Accountable; import io.github.jbellis.jvector.util.Bits; @@ -35,7 +36,6 @@ import java.io.Closeable; import java.io.IOException; -import java.util.function.Function; /** * Represents a graph-based vector index. Nodes are represented as ints, and edges are @@ -48,6 +48,8 @@ * in a View that should be created per accessing thread. */ public interface ImmutableGraphIndex extends AutoCloseable, Accountable { + int OMITTED = OrdinalMapper.OMITTED; // same as OrdinalMapper, since OrdinalMapper::oldToNew may return it + /** Returns the number of nodes in the graph */ @Deprecated default int size() { diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java index 9ed1a92dd..be67b0c6a 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java @@ -49,7 +49,6 @@ import java.util.concurrent.atomic.AtomicIntegerArray; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.StampedLock; -import java.util.function.Function; import java.util.stream.IntStream; /** @@ -542,8 +541,12 @@ public void save(DataOutput out) throws IOException { } var entryNode = entryPoint.get(); - assert entryNode.level == getMaxLevel(); - out.writeInt(entryNode.node); + if (entryNode != null) { + assert entryNode.level == getMaxLevel(); + out.writeInt(entryNode.node); + } else { + out.writeInt(OMITTED); + } for (int level = 0; level < layers.size(); level++) { out.writeInt(size(level)); @@ -618,7 +621,9 @@ public static OnHeapGraphIndex load(RandomAccessReader in, int dimension, double } graph.setDegrees(layerDegrees); - graph.updateEntryNode(new NodeAtLevel(graph.getMaxLevel(), entryNode)); + if (entryNode != OMITTED) { + graph.updateEntryNode(new NodeAtLevel(graph.getMaxLevel(), entryNode)); + } return graph; } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java index a5ff739f3..fa6a8e378 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java @@ -175,7 +175,7 @@ void writeFooter(ImmutableGraphIndex.View view, long headerOffset) throws IOExce var layerInfo = CommonHeader.LayerInfo.fromGraph(graph, ordinalMapper); var commonHeader = new CommonHeader(version, dimension, - ordinalMapper.oldToNew(view.entryNode().node), + view.entryNode() == null ? OrdinalMapper.OMITTED : ordinalMapper.oldToNew(view.entryNode().node), layerInfo, ordinalMapper.maxOrdinal() + 1); var header = new Header(commonHeader, featureMap); @@ -198,7 +198,7 @@ protected synchronized void writeHeader(ImmutableGraphIndex.View view, long star var layerInfo = CommonHeader.LayerInfo.fromGraph(graph, ordinalMapper); var commonHeader = new CommonHeader(version, dimension, - ordinalMapper.oldToNew(view.entryNode().node), + view.entryNode() == null ? OrdinalMapper.OMITTED : ordinalMapper.oldToNew(view.entryNode().node), layerInfo, ordinalMapper.maxOrdinal() + 1); var header = new Header(commonHeader, featureMap); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java index 5d0a1aecb..90a812c09 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java @@ -81,10 +81,10 @@ void write(IndexWriter out) throws IOException { out.writeInt(OnDiskGraphIndex.MAGIC); out.writeInt(version); } - out.writeInt(layerInfo.get(0).size); + out.writeInt(layerInfo.isEmpty() ? 0 : layerInfo.get(0).size); out.writeInt(dimension); out.writeInt(entryNode); - out.writeInt(layerInfo.get(0).degree); + out.writeInt(layerInfo.isEmpty() ? 0 : layerInfo.get(0).degree); if (version >= 4) { out.writeInt(idUpperBound); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index 3fb69d967..1bf6c704e 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -50,7 +50,6 @@ import java.util.Set; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -import java.util.function.Function; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -95,7 +94,11 @@ private OnDiskGraphIndex(ReaderSupplier readerSupplier, Header header, long neig this.version = header.common.version; this.layerInfo = header.common.layerInfo; this.dimension = header.common.dimension; - this.entryNode = new NodeAtLevel(header.common.layerInfo.size() - 1, header.common.entryNode); + if (header.common.entryNode == OMITTED) { + this.entryNode = null; + } else { + this.entryNode = new NodeAtLevel(header.common.layerInfo.size() - 1, header.common.entryNode); + } this.idUpperBound = header.common.idUpperBound; this.features = header.features; this.neighborsOffset = neighborsOffset; @@ -128,6 +131,9 @@ private List> getInMemoryLayers(RandomAccessReader in) private List> loadInMemoryLayers(RandomAccessReader in) throws IOException { var imn = new ArrayList>(layerInfo.size()); + if (layerInfo.isEmpty()) { + return imn; + } // For levels > 0, we load adjacency into memory imn.add(null); // L0 placeholder so we don't have to mangle indexing long L0size = idUpperBound * (inlineBlockSize + Integer.BYTES * (1L + 1L + layerInfo.get(0).degree)); @@ -333,12 +339,12 @@ public int getDimension() { @Override public int size(int level) { - return layerInfo.get(level).size; + return layerInfo.isEmpty() ? 0 : layerInfo.get(level).size; } @Override public int getDegree(int level) { - return layerInfo.get(level).degree; + return layerInfo.isEmpty() ? 0 : layerInfo.get(level).degree; } @Override @@ -435,7 +441,7 @@ public String toString() { @Override public int getMaxLevel() { - return entryNode.level; + return entryNode == null ? 0 : entryNode.level; } @Override diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java index 59b248584..659abecac 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java @@ -38,6 +38,7 @@ import static io.github.jbellis.jvector.TestUtil.assertGraphEquals; import static io.github.jbellis.jvector.graph.TestVectorGraph.createRandomFloatVectors; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; @ThreadLeakScope(ThreadLeakScope.Scope.NONE) @@ -156,6 +157,34 @@ public void testSaveAndLoad() throws IOException { } assertGraphEquals(graph, builder.graph); } + + @Test + public void testSaveAndLoadEmptyGraph() throws IOException { + int dimension = randomIntBetween(2, 32); + var ravv = MockVectorValues.empty(dimension); + + Supplier newBuilder = () -> + new GraphIndexBuilder(ravv, VectorSimilarityFunction.COSINE, 2, 10, 1.0f, 1.0f, true); + + var indexDataPath = testDirectory.resolve("index_builder_empty.data"); + var builder = newBuilder.get(); + + var graph = TestUtil.buildSequentially(builder, ravv); + + try (var out = TestUtil.openDataOutputStream(indexDataPath)) { + ((OnHeapGraphIndex) graph).setAllMutationsCompleted(); + ((OnHeapGraphIndex) graph).save(out); + } + + builder = newBuilder.get(); + try(var readerSupplier = new SimpleMappedReader.Supplier(indexDataPath)) { + builder.load(readerSupplier.get()); + } + + assertEquals(ravv.size(), builder.graph.size(0)); + assertNull(builder.graph.entryNode()); + assertGraphEquals(graph, builder.graph); + } // Because RandomAccessVectorValues is exposed in such a way that it allows for subsequent additions to the // vector source, we need to ensure that GraphIndexBuilder can handle this. diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/MockVectorValues.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/MockVectorValues.java index a5a23b245..8bb2dfa01 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/MockVectorValues.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/MockVectorValues.java @@ -39,6 +39,10 @@ public static MockVectorValues fromValues(VectorFloat[] values) { return new MockVectorValues(values[0].length(), values); } + public static MockVectorValues empty(int dimension) { + return new MockVectorValues(dimension, new VectorFloat[0]); + } + MockVectorValues(int dimension, VectorFloat[] denseValues) { this.dimension = dimension; this.denseValues = denseValues; From b742ee52010966352275918df0f7ffe39c824f1c Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Mon, 22 Jun 2026 08:24:33 -0400 Subject: [PATCH 2/6] Address code review comments Signed-off-by: Andriy Redko --- .../io/github/jbellis/jvector/graph/GraphIndexBuilder.java | 4 ++-- .../io/github/jbellis/jvector/graph/ImmutableGraphIndex.java | 2 +- .../io/github/jbellis/jvector/graph/OnHeapGraphIndex.java | 4 ++-- .../jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java | 4 ++-- .../github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java index d4432665e..4139a14b6 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/GraphIndexBuilder.java @@ -951,7 +951,7 @@ private void loadV4(RandomAccessReader in) throws IOException { } graph.setDegrees(layerDegrees); - if (entryNode != ImmutableGraphIndex.OMITTED) { + if (entryNode != ImmutableGraphIndex.ENTRY_NODE_ABSENT) { graph.updateEntryNode(new NodeAtLevel(graph.getMaxLevel(), entryNode)); } } @@ -986,7 +986,7 @@ private void loadV3(RandomAccessReader in, int size) throws IOException { graph.markComplete(new NodeAtLevel(0, nodeId)); } - if (entryNode != ImmutableGraphIndex.OMITTED) { + if (entryNode != ImmutableGraphIndex.ENTRY_NODE_ABSENT) { graph.updateEntryNode(new NodeAtLevel(0, entryNode)); } graph.setDegrees(List.of(maxDegree)); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java index 4bee5f73d..00b9cf0ad 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java @@ -48,7 +48,7 @@ * in a View that should be created per accessing thread. */ public interface ImmutableGraphIndex extends AutoCloseable, Accountable { - int OMITTED = OrdinalMapper.OMITTED; // same as OrdinalMapper, since OrdinalMapper::oldToNew may return it + int ENTRY_NODE_ABSENT = -1; /** Returns the number of nodes in the graph */ @Deprecated diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java index be67b0c6a..56787f843 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java @@ -545,7 +545,7 @@ public void save(DataOutput out) throws IOException { assert entryNode.level == getMaxLevel(); out.writeInt(entryNode.node); } else { - out.writeInt(OMITTED); + out.writeInt(ENTRY_NODE_ABSENT); } for (int level = 0; level < layers.size(); level++) { @@ -621,7 +621,7 @@ public static OnHeapGraphIndex load(RandomAccessReader in, int dimension, double } graph.setDegrees(layerDegrees); - if (entryNode != OMITTED) { + if (entryNode != ENTRY_NODE_ABSENT) { graph.updateEntryNode(new NodeAtLevel(graph.getMaxLevel(), entryNode)); } diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java index fa6a8e378..09d0d0ec0 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/AbstractGraphIndexWriter.java @@ -175,7 +175,7 @@ void writeFooter(ImmutableGraphIndex.View view, long headerOffset) throws IOExce var layerInfo = CommonHeader.LayerInfo.fromGraph(graph, ordinalMapper); var commonHeader = new CommonHeader(version, dimension, - view.entryNode() == null ? OrdinalMapper.OMITTED : ordinalMapper.oldToNew(view.entryNode().node), + view.entryNode() == null ? ImmutableGraphIndex.ENTRY_NODE_ABSENT : ordinalMapper.oldToNew(view.entryNode().node), layerInfo, ordinalMapper.maxOrdinal() + 1); var header = new Header(commonHeader, featureMap); @@ -198,7 +198,7 @@ protected synchronized void writeHeader(ImmutableGraphIndex.View view, long star var layerInfo = CommonHeader.LayerInfo.fromGraph(graph, ordinalMapper); var commonHeader = new CommonHeader(version, dimension, - view.entryNode() == null ? OrdinalMapper.OMITTED : ordinalMapper.oldToNew(view.entryNode().node), + view.entryNode() == null ? ImmutableGraphIndex.ENTRY_NODE_ABSENT : ordinalMapper.oldToNew(view.entryNode().node), layerInfo, ordinalMapper.maxOrdinal() + 1); var header = new Header(commonHeader, featureMap); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index 1bf6c704e..f4d064023 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -94,7 +94,7 @@ private OnDiskGraphIndex(ReaderSupplier readerSupplier, Header header, long neig this.version = header.common.version; this.layerInfo = header.common.layerInfo; this.dimension = header.common.dimension; - if (header.common.entryNode == OMITTED) { + if (header.common.entryNode == ENTRY_NODE_ABSENT) { this.entryNode = null; } else { this.entryNode = new NodeAtLevel(header.common.layerInfo.size() - 1, header.common.entryNode); From 4bdb31ac91144720913504538b087618e93b533a Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Mon, 22 Jun 2026 10:22:22 -0400 Subject: [PATCH 3/6] Added OnDiskGraphIndex test for empty graphs Signed-off-by: Andriy Redko --- .../io/github/jbellis/jvector/TestUtil.java | 103 ++++++++++++++++++ .../graph/disk/TestOnDiskGraphIndex.java | 5 +- 2 files changed, 107 insertions(+), 1 deletion(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java index eed263a09..e1e7172b2 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java @@ -543,4 +543,107 @@ public long ramBytesUsed() { throw new UnsupportedOperationException(); } } + + public static class EmptyGraphIndex implements ImmutableGraphIndex { + private final int dimension; + + public EmptyGraphIndex(int dimension, Random random) { + this.dimension = dimension; + } + + @Override + public long ramBytesUsed() { + return 0; + } + + @Override + public NodesIterator getNodes(int level) { + return NodesIterator.EMPTY_NODE_ITERATOR; + } + + @Override + public View getView() { + return new View() { + @Override + public void close() throws IOException { + } + + @Override + public int size() { + return 0; + } + + @Override + public void processNeighbors(int level, int node, ScoreFunction scoreFunction, IntMarker visited, + NeighborProcessor neighborProcessor) { + } + + @Override + public Bits liveNodes() { + return Bits.NONE; + } + + @Override + public NodesIterator getNeighborsIterator(int level, int node) { + return NodesIterator.EMPTY_NODE_ITERATOR; + } + + @Override + public NodeAtLevel entryNode() { + return null; + } + + @Override + public boolean contains(int level, int node) { + return false; + } + }; + } + + @Override + public int maxDegree() { + return 0; + } + + @Override + public List maxDegrees() { + return List.of(); + } + + @Override + public int getDimension() { + return dimension; + } + + @Override + public void close() throws IOException { + + } + + @Override + public boolean isHierarchical() { + return false; + } + + @Override + public int getMaxLevel() { + return 0; + } + + @Override + public int getDegree(int level) { + return 0; + } + + @Override + public double getAverageDegree(int level) { + return 0; + } + + @Override + public int size(int level) { + return 0; + } + + }; } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java index 29a8dca29..8930b720e 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/disk/TestOnDiskGraphIndex.java @@ -22,6 +22,7 @@ import io.github.jbellis.jvector.disk.SimpleMappedReader; import io.github.jbellis.jvector.graph.GraphIndexBuilder; import io.github.jbellis.jvector.graph.GraphSearcher; +import io.github.jbellis.jvector.graph.ImmutableGraphIndex; import io.github.jbellis.jvector.graph.ListRandomAccessVectorValues; import io.github.jbellis.jvector.graph.NodesIterator; import io.github.jbellis.jvector.graph.RandomAccessVectorValues; @@ -60,11 +61,13 @@ public class TestOnDiskGraphIndex extends RandomizedTest { private TestUtil.FullyConnectedGraphIndex fullyConnectedGraph; private TestUtil.RandomlyConnectedGraphIndex randomlyConnectedGraph; + private ImmutableGraphIndex emptyGraph; @Before public void setup() throws IOException { fullyConnectedGraph = new TestUtil.FullyConnectedGraphIndex(0, 6); randomlyConnectedGraph = new TestUtil.RandomlyConnectedGraphIndex(10, 4, getRandom()); + emptyGraph = new TestUtil.EmptyGraphIndex(10, getRandom()); testDirectory = Files.createTempDirectory(this.getClass().getSimpleName()); } @@ -75,7 +78,7 @@ public void tearDown() { @Test public void testSimpleGraphs() throws Exception { - for (var graph : List.of(fullyConnectedGraph, randomlyConnectedGraph)) + for (var graph : List.of(fullyConnectedGraph, randomlyConnectedGraph, emptyGraph)) { var outputPath = testDirectory.resolve("test_graph_" + graph.getClass().getSimpleName()); var ravv = new TestVectorGraph.CircularFloatVectorValues(graph.size(0)); From f808119fa6d243c00b308e2ac74c97145f6a062f Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Mon, 22 Jun 2026 11:58:39 -0400 Subject: [PATCH 4/6] Address code review comments Signed-off-by: Andriy Redko --- .../io/github/jbellis/jvector/graph/OnHeapGraphIndex.java | 2 +- .../io/github/jbellis/jvector/graph/disk/CommonHeader.java | 4 ++-- .../jbellis/jvector/graph/disk/OnDiskGraphIndex.java | 7 ++----- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java index 56787f843..c1b44e991 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/OnHeapGraphIndex.java @@ -350,7 +350,7 @@ public double getAverageDegree(int level) { public int getMaxLevel() { for (int lvl = 0; lvl < layers.size(); lvl++) { if (layers.get(lvl).size() == 0) { - return lvl - 1; + return (lvl > 0) ? lvl - 1 : 0; } } return layers.size() - 1; diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java index 90a812c09..5d0a1aecb 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/CommonHeader.java @@ -81,10 +81,10 @@ void write(IndexWriter out) throws IOException { out.writeInt(OnDiskGraphIndex.MAGIC); out.writeInt(version); } - out.writeInt(layerInfo.isEmpty() ? 0 : layerInfo.get(0).size); + out.writeInt(layerInfo.get(0).size); out.writeInt(dimension); out.writeInt(entryNode); - out.writeInt(layerInfo.isEmpty() ? 0 : layerInfo.get(0).degree); + out.writeInt(layerInfo.get(0).degree); if (version >= 4) { out.writeInt(idUpperBound); diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java index f4d064023..b445ceb20 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/disk/OnDiskGraphIndex.java @@ -131,9 +131,6 @@ private List> getInMemoryLayers(RandomAccessReader in) private List> loadInMemoryLayers(RandomAccessReader in) throws IOException { var imn = new ArrayList>(layerInfo.size()); - if (layerInfo.isEmpty()) { - return imn; - } // For levels > 0, we load adjacency into memory imn.add(null); // L0 placeholder so we don't have to mangle indexing long L0size = idUpperBound * (inlineBlockSize + Integer.BYTES * (1L + 1L + layerInfo.get(0).degree)); @@ -339,12 +336,12 @@ public int getDimension() { @Override public int size(int level) { - return layerInfo.isEmpty() ? 0 : layerInfo.get(level).size; + return layerInfo.get(level).size; } @Override public int getDegree(int level) { - return layerInfo.isEmpty() ? 0 : layerInfo.get(level).degree; + return layerInfo.get(level).degree; } @Override From fb351c39387c1b8eeeb283dcb72675d55d753e99 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Thu, 25 Jun 2026 09:52:07 -0400 Subject: [PATCH 5/6] Address code review comments Signed-off-by: Andriy Redko --- .../io/github/jbellis/jvector/TestUtil.java | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java index e1e7172b2..6542c1757 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java @@ -546,9 +546,11 @@ public long ramBytesUsed() { public static class EmptyGraphIndex implements ImmutableGraphIndex { private final int dimension; + private final List layerInfo; public EmptyGraphIndex(int dimension, Random random) { this.dimension = dimension; + this.layerInfo = List.of(new CommonHeader.LayerInfo(0, 0)); } @Override @@ -576,11 +578,12 @@ public int size() { @Override public void processNeighbors(int level, int node, ScoreFunction scoreFunction, IntMarker visited, NeighborProcessor neighborProcessor) { + throw new IllegalStateException("Should not be called, empty graph has no nodes"); } @Override public Bits liveNodes() { - return Bits.NONE; + return Bits.ALL; } @Override @@ -602,12 +605,12 @@ public boolean contains(int level, int node) { @Override public int maxDegree() { - return 0; + return layerInfo.stream().mapToInt(li -> li.degree).max().orElseThrow(); } @Override public List maxDegrees() { - return List.of(); + throw new NotImplementedException(); } @Override @@ -627,22 +630,22 @@ public boolean isHierarchical() { @Override public int getMaxLevel() { - return 0; + return layerInfo.size() - 1; } @Override public int getDegree(int level) { - return 0; + return layerInfo.get(level).degree; } @Override public double getAverageDegree(int level) { - return 0; + throw new NotImplementedException(); } @Override public int size(int level) { - return 0; + return layerInfo.get(level).size; } }; From 11b60197f3d75b4e16d802f723e91f91ed8b1ac3 Mon Sep 17 00:00:00 2001 From: Andriy Redko Date: Thu, 25 Jun 2026 09:54:57 -0400 Subject: [PATCH 6/6] Address code review comments Signed-off-by: Andriy Redko --- .../io/github/jbellis/jvector/graph/ImmutableGraphIndex.java | 1 + .../src/test/java/io/github/jbellis/jvector/TestUtil.java | 1 - .../io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java index 00b9cf0ad..6482431cd 100644 --- a/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java +++ b/jvector-base/src/main/java/io/github/jbellis/jvector/graph/ImmutableGraphIndex.java @@ -48,6 +48,7 @@ * in a View that should be created per accessing thread. */ public interface ImmutableGraphIndex extends AutoCloseable, Accountable { + /** Marks entry node as absent (fe, empty graph) */ int ENTRY_NODE_ABSENT = -1; /** Returns the number of nodes in the graph */ diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java index 6542c1757..248be18ce 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/TestUtil.java @@ -647,6 +647,5 @@ public double getAverageDegree(int level) { public int size(int level) { return layerInfo.get(level).size; } - }; } diff --git a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java index 659abecac..9cdb693a3 100644 --- a/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java +++ b/jvector-tests/src/test/java/io/github/jbellis/jvector/graph/GraphIndexBuilderTest.java @@ -157,7 +157,7 @@ public void testSaveAndLoad() throws IOException { } assertGraphEquals(graph, builder.graph); } - + @Test public void testSaveAndLoadEmptyGraph() throws IOException { int dimension = randomIntBetween(2, 32);