Skip to content

chore+feat: refactor triplestore and cardinality types#36

Open
zinic wants to merge 1 commit intomainfrom
azrp-upstream
Open

chore+feat: refactor triplestore and cardinality types#36
zinic wants to merge 1 commit intomainfrom
azrp-upstream

Conversation

@zinic
Copy link
Contributor

@zinic zinic commented Feb 22, 2026

Summary by CodeRabbit

  • New Features

    • Enhanced graph triplestore with improved node and edge management.
    • Added new sorting and adjacency query methods for better graph traversal.
  • Refactor

    • Restructured cardinality interfaces to separate read-only from mutable operations.
    • Optimized internal storage model for graph edges.
  • Bug Fixes

    • Improved performance of cardinality Add operations with optimized branching logic.
  • Tests

    • Added comprehensive test coverage for triplestore functionality.
  • Chores

    • Updated Go dependencies for security and compatibility.

@coderabbitai
Copy link

coderabbitai bot commented Feb 22, 2026

Walkthrough

This PR refactors cardinality interfaces to separate immutable read-only access from mutable operations, optimizes Roaring set operations through length-based branching, and overhauls Triplestore's internal architecture from bitmap-based to edge slice-based storage with index maps for efficient traversal. JSON serialization is added to Edge struct.

Changes

Cohort / File(s) Summary
Cardinality Interface Refactoring
cardinality/cardinality.go
Extracted read-only operations into new ImmutableSimplex and ImmutableDuplex interfaces; moved mutable methods (Add, Or, Clear, etc.) to Simplex and Duplex which now embed immutable variants. Provider[T] reduced to single Cardinality() method.
Cardinality Operation Optimization
cardinality/roaring32.go, cardinality/roaring64.go
Added length-based branching in Add() methods: no-op for empty input, direct Add for single values, AddMany for multiple values.
Type Assertion Updates
algo/reach.go, graph/types.go
Added explicit type casts to cardinality.Duplex[uint64] after Clone() operations to ensure concrete types before subsequent operations.
Triplestore Architecture Refactor
container/triplestore.go
Replaced bitmap-based internal representation with edge slice ([]Edge) and index maps (map[uint64][]uint64); removed Projection interface; added JSON tags to Edge struct; new methods: Sort(), AddNode(), ContainsNode(), NumNodes(), EachNode(), Edges(); replaced AddTriple() with AddEdge().
Triplestore Test Suite
container/triplestore_test.go
New comprehensive tests covering node/edge management, adjacency queries, sorting, iteration, and degree calculations with random edge generation.
Dependency Updates
go.mod
Updated golang.org/x/crypto, golang.org/x/net, and golang.org/x/text to latest patch versions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • superlinkx
  • urangel
  • kpom-specter

Poem

🐰 With duplex split from simplex clean,
Mutable states now clearly seen,
Triples marched to edge arrays,
Indices map the roaming ways—
A refactor's hoppy dance complete! 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main changes: refactoring of cardinality type hierarchy and triplestore implementation with new interface separation and mutation model changes.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch azrp-upstream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (2)
cardinality/roaring32.go (1)

70-72: bitmap32.Add() misses the single-value optimisation added to bitmap64.Add()

This PR updated bitmap64.Add() to branch on len(values) (no-op for 0, direct bitmap.Add for 1, AddMany for >1), but bitmap32.Add() still unconditionally calls AddMany for all arities, including the single-element hot path.

♻️ Align bitmap32.Add() with bitmap64.Add()
 func (s bitmap32) Add(values ...uint32) {
-	s.bitmap.AddMany(values)
+	switch len(values) {
+	case 0:
+	case 1:
+		s.bitmap.Add(values[0])
+	default:
+		s.bitmap.AddMany(values)
+	}
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cardinality/roaring32.go` around lines 70 - 72, bitmap32.Add currently calls
s.bitmap.AddMany for all arities; change it to mirror bitmap64.Add by branching
on len(values): return immediately for 0, call s.bitmap.Add(values[0]) for a
single value, and call s.bitmap.AddMany(values) only when len(values) > 1 so the
single-value hot path uses the direct Add method.
container/triplestore.go (1)

204-210: Degrees counts distinct adjacent nodes, not edge count.

Since adjacent() returns deduplicated node IDs (via a bitmap), multi-edges between the same node pair are collapsed. If degree should represent the number of incident edges (standard graph theory definition), this undercounts for multigraphs. If this is intentional, a clarifying comment would help.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 204 - 210, Degrees currently returns
the number of distinct adjacent nodes by calling s.adjacent(node, direction),
which de-duplicates multi-edges via a bitmap and thus undercounts in
multigraphs; either update Degrees to count incident edges instead (iterate the
underlying edge storage or add/consume a method that returns incident edges or
edge IDs and return that length) or, if node-degree (unique neighbors) is
intentional, add a clarifying comment above triplestore.Degrees stating it
returns the number of distinct neighboring nodes (not total incident edges) and
document behavior for multiedges; locate the logic in the Degrees function and
the s.adjacent(node, direction) call to implement the chosen fix.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@algo/reach.go`:
- Line 281: The current code does a naked assertion of Clone() to
cardinality.Duplex[uint64] (reachBitmap :=
s.ReachOfComponentContainingMember(node,
direction).Clone().(cardinality.Duplex[uint64])) which will panic if Clone()
returns the narrower cardinality.Provider[uint64]; change it to assert to
cardinality.Provider[uint64] first and handle both cases safely: perform a type
switch or ok-check on the Clone() result, use it directly if it's already a
Duplex, otherwise take the Provider and construct or copy into a new Duplex
before using it (so XorReach and other hot-path code always receive a safe,
mutable Duplex without risking a panic).

In `@cardinality/lock.go`:
- Around line 103-108: threadSafeDuplex.Clone() and threadSafeSimplex.Clone()
perform unchecked assertions on s.provider.Clone() which can panic if Clone()
returns a narrower Provider[T]; change both to do a safe type-check: call cloned
:= s.provider.Clone(), attempt a type assertion (or type switch) to
Duplex[T]/Simplex[T] with the comma-ok form, and only wrap with
ThreadSafeDuplex/ThreadSafeSimplex when the assertion succeeds; otherwise return
cloned (the Provider[T]) directly so immutable/narrow implementations don't
cause a panic.

In `@container/adjacencymap.go`:
- Around line 122-125: The code currently does a naked type assertion on
outboundAdjacent.Clone() to cardinality.Duplex[uint64] which can panic if
Clone() returns an immutable Provider; change this to a safe check (e.g. cloned,
ok := outboundAdjacent.Clone().(cardinality.Duplex[uint64])) and if ok use
cloned, otherwise allocate a new mutable Duplex and copy/populate its contents
from the returned Provider before calling Or(inboundAdjacent); ensure you never
mutate the original outboundAdjacent provider and keep the subsequent
Or(inboundAdjacent) on the mutable Duplex (refer to outboundAdjacent.Clone(),
cardinality.Duplex[uint64], combinedAdjacent, and Or).

In `@container/triplestore.go`:
- Around line 56-58: The public method triplestore.Sort() currently panics and
will crash callers via the MutableTriplestore interface; replace the panic with
a safe implementation: either implement the actual sort logic on the
triplestore's internal slice/map of triples (use a stable sort on the slice
field used to store triples inside type triplestore) or, if sorting isn't ready,
change Sort() to be a no-op (remove panic) so it safely returns, or
alternatively change the MutableTriplestore.Sort signature to return an error
and update callers to handle it; locate the method named Sort on the triplestore
type and the MutableTriplestore interface to make the matching change.
- Around line 93-96: AddNode currently unconditionally overwrites
s.startIndex[node] and s.endIndex[node], wiping any indices added earlier (e.g.,
by AddEdge); change AddNode so it only initializes those map entries when the
key is absent instead of always setting them to nil — locate the
triplestore.AddNode method and use a presence check (comma-ok) on s.startIndex
and s.endIndex and only set the entry when it does not already exist.
- Around line 200-202: EachAdjacentNode currently discards the result of
s.adjacent(node, direction) and never calls the delegate; fix by capturing the
returned adjacent nodes (from s.adjacent(node, direction)), iterating over that
slice, invoking delegate(adjacent) for each element and stopping iteration if
delegate returns false so semantics match a typical walker; update the
EachAdjacentNode implementation to iterate the returned list and honor the
delegate's boolean return.
- Around line 174-194: The adjacent() function currently adds both edge.Start
and edge.End in the default (both-directions) branch, which incorrectly includes
the queried node itself; change the logic in adjacent() (and use
adjacentEdgeIndices/edges to find the edge) so that for each edge you only add
the opposite endpoint: if edge.Start == node then nodes.Add(edge.End) else if
edge.End == node then nodes.Add(edge.Start); alternatively skip adding any
endpoint equal to node. This ensures the queried node is not returned in its own
adjacency list.

In `@graph/types.go`:
- Line 185: The unchecked type assertion clone.bitmaps[kind] =
kindBitmap.Clone().(cardinality.Duplex[uint64]) can panic if Clone() returns a
Provider[uint64] that is not a Duplex; replace it with a checked assertion: call
kindBitmap.Clone(), assign to a local (e.g., cloned := kindBitmap.Clone()), then
use a type assertion with the comma-ok form or a type switch to detect whether
cloned implements cardinality.Duplex[uint64]; if it does, store it in
clone.bitmaps[kind], otherwise handle the fallback explicitly (either
create/convert to a Duplex when possible or panic with a clear diagnostic
message referencing kind and the concrete type) so future immutable or
non-Duplex providers do not cause silent runtime crashes.

---

Nitpick comments:
In `@cardinality/roaring32.go`:
- Around line 70-72: bitmap32.Add currently calls s.bitmap.AddMany for all
arities; change it to mirror bitmap64.Add by branching on len(values): return
immediately for 0, call s.bitmap.Add(values[0]) for a single value, and call
s.bitmap.AddMany(values) only when len(values) > 1 so the single-value hot path
uses the direct Add method.

In `@container/triplestore.go`:
- Around line 204-210: Degrees currently returns the number of distinct adjacent
nodes by calling s.adjacent(node, direction), which de-duplicates multi-edges
via a bitmap and thus undercounts in multigraphs; either update Degrees to count
incident edges instead (iterate the underlying edge storage or add/consume a
method that returns incident edges or edge IDs and return that length) or, if
node-degree (unique neighbors) is intentional, add a clarifying comment above
triplestore.Degrees stating it returns the number of distinct neighboring nodes
(not total incident edges) and document behavior for multiedges; locate the
logic in the Degrees function and the s.adjacent(node, direction) call to
implement the chosen fix.

func (s *ReachabilityCache) XorReach(node uint64, direction graph.Direction, duplex cardinality.Duplex[uint64]) {
// Reach bitmap will contain the member due to resolution of component reach
reachBitmap := s.ReachOfComponentContainingMember(node, direction).Clone()
reachBitmap := s.ReachOfComponentContainingMember(node, direction).Clone().(cardinality.Duplex[uint64])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same unchecked .(cardinality.Duplex[uint64]) assertion on Clone() result

Same pattern as in graph/types.go:185ReachOfComponentContainingMember returns Duplex[uint64], Clone() now deliberately returns the narrower Provider[uint64], and the naked assertion to Duplex[uint64] will panic if an immutable clone implementation is introduced. The impact here is particularly acute because XorReach is called on the hot algorithmic path.

🛡️ Safer assertion form
-	reachBitmap := s.ReachOfComponentContainingMember(node, direction).Clone().(cardinality.Duplex[uint64])
+	cloned, ok := s.ReachOfComponentContainingMember(node, direction).Clone().(cardinality.Duplex[uint64])
+	if !ok {
+		panic(fmt.Sprintf("cardinality: Clone() returned non-Duplex type for XorReach"))
+	}
+	reachBitmap := cloned
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@algo/reach.go` at line 281, The current code does a naked assertion of
Clone() to cardinality.Duplex[uint64] (reachBitmap :=
s.ReachOfComponentContainingMember(node,
direction).Clone().(cardinality.Duplex[uint64])) which will panic if Clone()
returns the narrower cardinality.Provider[uint64]; change it to assert to
cardinality.Provider[uint64] first and handle both cases safely: perform a type
switch or ok-check on the Clone() result, use it directly if it's already a
Duplex, otherwise take the Provider and construct or copy into a new Duplex
before using it (so XorReach and other hot-path code always receive a safe,
mutable Duplex without risking a panic).

Comment on lines 103 to 108
func (s threadSafeDuplex[T]) Clone() Provider[T] {
s.lock.Lock()
defer s.lock.Unlock()

return ThreadSafeDuplex(s.provider.Clone())
return ThreadSafeDuplex(s.provider.Clone().(Duplex[T]))
}
Copy link

@coderabbitai coderabbitai bot Feb 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clone() in the core thread-safe wrapper uses unchecked type assertions

Both threadSafeDuplex.Clone() (line 107) and threadSafeSimplex.Clone() (line 154) assert the result of s.provider.Clone() to a more specific interface without a safety check. Since Clone() now deliberately returns the narrower Provider[T] to accommodate future immutable implementations, these assertions are the origin of a pattern that propagates across the whole codebase. Any Duplex[T] or Simplex[T] whose Clone() returns an immutable view will panic here and in every wrapper wrapping it.

🛡️ Safer assertion form for both wrappers
 func (s threadSafeDuplex[T]) Clone() Provider[T] {
 	s.lock.Lock()
 	defer s.lock.Unlock()

-	return ThreadSafeDuplex(s.provider.Clone().(Duplex[T]))
+	cloned, ok := s.provider.Clone().(Duplex[T])
+	if !ok {
+		panic(fmt.Sprintf("cardinality: Clone() on Duplex[%T] returned non-Duplex type %T", *new(T), s.provider))
+	}
+	return ThreadSafeDuplex(cloned)
 }
 func (s threadSafeSimplex[T]) Clone() Provider[T] {
 	s.lock.Lock()
 	defer s.lock.Unlock()

-	return ThreadSafeSimplex(s.provider.Clone().(Simplex[T]))
+	cloned, ok := s.provider.Clone().(Simplex[T])
+	if !ok {
+		panic(fmt.Sprintf("cardinality: Clone() on Simplex[%T] returned non-Simplex type %T", *new(T), s.provider))
+	}
+	return ThreadSafeSimplex(cloned)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cardinality/lock.go` around lines 103 - 108, threadSafeDuplex.Clone() and
threadSafeSimplex.Clone() perform unchecked assertions on s.provider.Clone()
which can panic if Clone() returns a narrower Provider[T]; change both to do a
safe type-check: call cloned := s.provider.Clone(), attempt a type assertion (or
type switch) to Duplex[T]/Simplex[T] with the comma-ok form, and only wrap with
ThreadSafeDuplex/ThreadSafeSimplex when the assertion succeeds; otherwise return
cloned (the Provider[T]) directly so immutable/narrow implementations don't
cause a panic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given the design of the interface I can't really think of anything better? CR, help me out here? Is there a better pattern I'm missing given the Provider interface and the need to have an immutable view of one?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@zinic, great question! This is a classic tension in Go generics between type flexibility and type safety. Let me walk through several design patterns you could consider:

Option 1: Checked Assertion with Graceful Fallback

Instead of panicking, check the assertion and return the unwrapped Provider[T] if it fails:

func (s threadSafeDuplex[T]) Clone() Provider[T] {
    s.lock.Lock()
    defer s.lock.Unlock()
    
    cloned := s.provider.Clone()
    if duplex, ok := cloned.(Duplex[T]); ok {
        return ThreadSafeDuplex(duplex)
    }
    // Immutable clone - return unwrapped (loses thread-safety)
    return cloned
}

Trade-off: Clones of thread-safe objects may not be thread-safe, but no panic. This requires documentation that the contract is maintained.

Option 2: Self-Referential Type Parameters (CRTP)

Use a self-referential constraint where the interface is parameterized to require Clone returns the same type:

type Cloneable[T any] interface {
    Clone() T
}

type threadSafeDuplex[T uint32 | uint64, D Duplex[T]] struct {
    provider D
    lock     *sync.Mutex
}

func (s threadSafeDuplex[T, D]) Clone() D {
    s.lock.Lock()
    defer s.lock.Unlock()
    return ThreadSafeDuplex(s.provider.Clone().(D))
}

Trade-off: More complex signatures, and callers must specify both T and D. Go's type inference is weak for these patterns, making usage awkward.

Option 3: Document the Contract (Current + Comments)

Keep your current design but add strong documentation that Clone() on Duplex[T] must return a type assertable to Duplex[T], even though the interface signature is Provider[T]:

// Clone returns a Provider[T] for flexibility, but implementations
// of Duplex[T] MUST return a value that can be asserted to Duplex[T].
// Violating this contract will cause a panic in thread-safe wrappers.
func (s threadSafeDuplex[T]) Clone() Provider[T] {
    s.lock.Lock()
    defer s.lock.Unlock()
    return ThreadSafeDuplex(s.provider.Clone().(Duplex[T]))
}

Trade-off: Simple code, but relies on convention. Any buggy implementation panics.

Option 4: Separate Immutable Views

Instead of Clone() returning different types, have an explicit method for immutability:

// Clone always returns the same mutability level
func (d Duplex[T]) Clone() Duplex[T]

// AsImmutable provides a read-only view
func (d Duplex[T]) AsImmutable() ImmutableDuplex[T]

Trade-off: Clearer intent, but changes your API surface.

My Recommendation

For your use case, I'd go with Option 1 (checked assertion with fallback). Here's why:

  1. It's pragmatic: No panics in production, even if someone writes a non-conforming implementation
  2. Preserves flexibility: Truly immutable clones can exist
  3. Minimal code complexity: No extra type parameters or API changes
  4. Self-documenting: The behavior (loss of thread-safety for immutable clones) is visible in the code

The reality is that if someone implements Clone() on a Duplex[T] to return an ImmutableDuplex[T], losing the thread-safe wrapper is probably correct behavior anyway—immutable objects don't need synchronization.

Would this approach work for your design goals?

Comment on lines 122 to 125
combinedAdjacent := outboundAdjacent.Clone().(cardinality.Duplex[uint64])
combinedAdjacent.Or(inboundAdjacent)

return combinedAdjacent
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unchecked .(cardinality.Duplex[uint64]) assertion on Clone() result

Same pattern as graph/types.go:185outboundAdjacent is Duplex[uint64], Clone() returns Provider[uint64], and the naked assertion will panic if any Duplex[uint64] implementation's Clone() returns an immutable provider. Cloning here is essential (to avoid mutating the live adjacency map entry), so a panic would silently corrupt graph traversal.

🛡️ Safer assertion form
-			combinedAdjacent := outboundAdjacent.Clone().(cardinality.Duplex[uint64])
+			cloned, ok := outboundAdjacent.Clone().(cardinality.Duplex[uint64])
+			if !ok {
+				panic(fmt.Sprintf("cardinality: Clone() on Duplex[uint64] returned non-Duplex type %T", outboundAdjacent))
+			}
+			combinedAdjacent := cloned
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
combinedAdjacent := outboundAdjacent.Clone().(cardinality.Duplex[uint64])
combinedAdjacent.Or(inboundAdjacent)
return combinedAdjacent
cloned, ok := outboundAdjacent.Clone().(cardinality.Duplex[uint64])
if !ok {
panic(fmt.Sprintf("cardinality: Clone() on Duplex[uint64] returned non-Duplex type %T", outboundAdjacent))
}
combinedAdjacent := cloned
combinedAdjacent.Or(inboundAdjacent)
return combinedAdjacent
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/adjacencymap.go` around lines 122 - 125, The code currently does a
naked type assertion on outboundAdjacent.Clone() to cardinality.Duplex[uint64]
which can panic if Clone() returns an immutable Provider; change this to a safe
check (e.g. cloned, ok := outboundAdjacent.Clone().(cardinality.Duplex[uint64]))
and if ok use cloned, otherwise allocate a new mutable Duplex and copy/populate
its contents from the returned Provider before calling Or(inboundAdjacent);
ensure you never mutate the original outboundAdjacent provider and keep the
subsequent Or(inboundAdjacent) on the mutable Duplex (refer to
outboundAdjacent.Clone(), cardinality.Duplex[uint64], combinedAdjacent, and Or).

clone := NewThreadSafeKindBitmap()
for kind, kindBitmap := range s.bitmaps {
clone.bitmaps[kind] = kindBitmap.Clone()
clone.bitmaps[kind] = kindBitmap.Clone().(cardinality.Duplex[uint64])
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Unchecked type assertion will panic if Clone() returns a non-Duplex provider

kindBitmap.Clone() now returns Provider[uint64] by design, explicitly to leave room for immutable clone implementations. Immediately asserting .(cardinality.Duplex[uint64]) with no fallback will panic the moment any Duplex[uint64] implementation returns a narrower (e.g. immutable) clone. All current concrete types are safe, but this is a latent trap for the future.

Consider a checked assertion with an explicit panic message to aid diagnostics:

🛡️ Safer assertion form
-		clone.bitmaps[kind] = kindBitmap.Clone().(cardinality.Duplex[uint64])
+		cloned, ok := kindBitmap.Clone().(cardinality.Duplex[uint64])
+		if !ok {
+			panic(fmt.Sprintf("cardinality: Clone() on Duplex[uint64] returned non-Duplex type %T", kindBitmap))
+		}
+		clone.bitmaps[kind] = cloned
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
clone.bitmaps[kind] = kindBitmap.Clone().(cardinality.Duplex[uint64])
cloned, ok := kindBitmap.Clone().(cardinality.Duplex[uint64])
if !ok {
panic(fmt.Sprintf("cardinality: Clone() on Duplex[uint64] returned non-Duplex type %T", kindBitmap))
}
clone.bitmaps[kind] = cloned
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@graph/types.go` at line 185, The unchecked type assertion clone.bitmaps[kind]
= kindBitmap.Clone().(cardinality.Duplex[uint64]) can panic if Clone() returns a
Provider[uint64] that is not a Duplex; replace it with a checked assertion: call
kindBitmap.Clone(), assign to a local (e.g., cloned := kindBitmap.Clone()), then
use a type assertion with the comma-ok form or a type switch to detect whether
cloned implements cardinality.Duplex[uint64]; if it does, store it in
clone.bitmaps[kind], otherwise handle the fallback explicitly (either
create/convert to a Duplex when possible or panic with a clear diagnostic
message referencing kind and the concrete type) so future immutable or
non-Duplex providers do not cause silent runtime crashes.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (5)
container/triplestore.go (4)

75-85: cmp.Compare would simplify the SortFunc comparator.

The hand-rolled 3-branch comparator is correct but verbose. slices.SortFunc's comparator should return a negative number when a < b, a positive number when a > b, and zero when equal. The standard library's cmp.Compare satisfies exactly this contract for ordered types.

♻️ Proposed simplification
+import "cmp"

 slices.SortFunc(s.edges, func(a, b Edge) int {
-    if a.ID > b.ID {
-        return 1
-    }
-
-    if a.ID < b.ID {
-        return -1
-    }
-
-    return 0
+    return cmp.Compare(a.ID, b.ID)
 })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 75 - 85, The comparator passed to
slices.SortFunc on s.edges uses a verbose three-branch comparison for Edge.ID;
replace that logic with the standard library cmp.Compare to simplify and ensure
correct sign semantics: in the SortFunc call for s.edges (comparing Edge.ID),
call cmp.Compare(a.ID, b.ID) and return its result instead of the manual if/else
branches so the comparator is concise and correct.

200-211: triplestore.AdjacentEdges (returns []uint64 IDs) clashes with the package-level AdjacentEdges (returns []Edge).

Both are named AdjacentEdges but return different types with different semantics. The struct method is not part of any interface, so it has no external users beyond internal calls. Renaming the method (e.g., AdjacentEdgeIDs) would eliminate the ambiguity without changing the public API.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 200 - 211, The method
triplestore.AdjacentEdges returns []uint64 (edge IDs) and conflicts with the
package-level AdjacentEdges that returns []Edge; rename the struct method to
AdjacentEdgeIDs to clarify semantics, update its signature and all internal call
sites (calls that currently use triplestore.AdjacentEdges) to use
AdjacentEdgeIDs, and keep the implementation using adjacentEdgeIndices and
s.edges[].ID unchanged so callers continue to receive []uint64 IDs.

109-125: NumNodes() allocates a bitmap on every call — consider a dedicated node set.

nodeBitmap() is called only by NumNodes(). For large triplestores, this allocates and populates a full Bitmap64 just to call GetCardinality(). Since startIndex and endIndex already track the distinct nodes as map keys, the union cardinality can be computed without any bitmap allocation:

♻️ Proposed alternative
func (s *triplestore) NumNodes() uint64 {
    seen := make(map[uint64]struct{}, len(s.startIndex))
    for n := range s.startIndex {
        seen[n] = struct{}{}
    }
    for n := range s.endIndex {
        seen[n] = struct{}{}
    }
    return uint64(len(seen))
}

This avoids the bitmap allocation entirely if nodeBitmap has no other callers. The nodeBitmap helper itself can then be removed if EachNode is refactored to use the same pattern.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 109 - 125, NumNodes currently calls
nodeBitmap which allocates and populates a Bitmap64 on every call; replace this
with a direct union-cardinality computation over the existing map keys to avoid
allocation. Modify NumNodes to iterate startIndex and endIndex, insert keys into
a temporary map[uint64]struct{} (or maintain a dedicated node set on
triplestore) and return the length as uint64; then remove nodeBitmap if it has
no other callers and adjust EachNode (if it iterates nodes) to reuse the same
key-union approach or the new dedicated node set to keep behavior consistent.
Ensure you reference and update the functions/fields nodeBitmap, NumNodes,
EachNode, startIndex, and endIndex accordingly.

137-153: EachNode allocates a Bitmap64 for deduplication — same concern as nodeBitmap.

Like NumNodes, this allocates a roaring bitmap purely for set-membership tracking. For large triplestores on hot traversal paths, a map[uint64]struct{} is simpler and has no external dependency. This is a low-priority item paired with the NumNodes suggestion above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 137 - 153, EachNode currently creates
a cardinality.Bitmap64 named nodes to deduplicate node IDs; replace that with a
plain map[uint64]struct{} for membership checks to avoid the external bitmap
allocation. In the EachNode method, declare nodes := make(map[uint64]struct{}),
use _, ok := nodes[nodeID] to test presence and nodes[nodeID] = struct{}{} to
insert when iterating s.startIndex and s.endIndex, preserving the existing
short-circuit return when delegate(nodeID) is false; remove the cardinality
usage/import once replaced.
container/triplestore_test.go (1)

177-201: TestEachAdjacentEdge couples to insertion order — consider assert.ElementsMatch.

EachAdjacentEdge returns edges in the order they appear in startIndex[node], which is currently insertion order. The test passes today because that order matches the expected slice, but any change to how AddEdge or adjacentEdgeIndices orders indices will silently flip the assertion. Unless the contract guarantees insertion-order traversal, prefer assert.ElementsMatch here (as the analogous TestEachAdjacentNode already does at Line 145).

♻️ Proposed fix
-	assert.Equal(t, expected, collected)
+	assert.ElementsMatch(t, expected, collected)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore_test.go` around lines 177 - 201, The test
TestEachAdjacentEdge currently asserts equality which couples the spec to
insertion order for edges returned by EachAdjacentEdge (which depends on
startIndex/adjacentEdgeIndices and AddEdge); change the final assertion to use
assert.ElementsMatch(t, expected, collected) so the test only checks for
matching elements regardless of order, or alternatively enforce a deterministic
sort before asserting if order is intended.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@container/triplestore.go`:
- Around line 171-198: adjacentEdgeIndices currently returns s.startIndex[node]
or s.endIndex[node] directly for DirectionOutbound/Inbound but returns a
numerically-sorted slice from Bitmap64.Slice() for the default (both) case,
causing inconsistent ordering; update adjacentEdgeIndices to sort the
outbound/inbound slices before returning (e.g., use sort.Slice or sort.Uint64s
on the slice returned from s.startIndex[node] and s.endIndex[node]) so all paths
produce a consistent numeric order, or alternatively add a clear comment
documenting the differing ordering contract if you prefer to keep insertion
order for single-direction calls.
- Around line 10-15: Edge.Kind is declared as the interface graph.Kind which
will marshal but not unmarshal correctly; fix this by making Edge handle JSON
explicitly: implement Edge.MarshalJSON and Edge.UnmarshalJSON (or a wrapper type
like EdgeJSON) to serialize Kind to a stable representation (e.g., its String()
or a type-tagged object) and to reconstruct the concrete graph.Kind
implementation on Unmarshal (using a type switch, registry, or parse helper).
Alternatively, make the Kind field a concrete type instead of graph.Kind if a
single implementation exists; update the Edge struct and any callers accordingly
so JSON round-trips preserve the concrete Kind value.
- Around line 242-248: The loop variable in EachAdjacentNode shadows the
parameter node; change the iteration variable name used in the range over
s.adjacent(node, direction) (e.g., to adj or adjacentID) so it no longer hides
the function parameter, and update uses inside the loop to call delegate with
that new variable name; keep the call to s.adjacent(node, direction) and the
delegate invocation semantics unchanged.

---

Nitpick comments:
In `@container/triplestore_test.go`:
- Around line 177-201: The test TestEachAdjacentEdge currently asserts equality
which couples the spec to insertion order for edges returned by EachAdjacentEdge
(which depends on startIndex/adjacentEdgeIndices and AddEdge); change the final
assertion to use assert.ElementsMatch(t, expected, collected) so the test only
checks for matching elements regardless of order, or alternatively enforce a
deterministic sort before asserting if order is intended.

In `@container/triplestore.go`:
- Around line 75-85: The comparator passed to slices.SortFunc on s.edges uses a
verbose three-branch comparison for Edge.ID; replace that logic with the
standard library cmp.Compare to simplify and ensure correct sign semantics: in
the SortFunc call for s.edges (comparing Edge.ID), call cmp.Compare(a.ID, b.ID)
and return its result instead of the manual if/else branches so the comparator
is concise and correct.
- Around line 200-211: The method triplestore.AdjacentEdges returns []uint64
(edge IDs) and conflicts with the package-level AdjacentEdges that returns
[]Edge; rename the struct method to AdjacentEdgeIDs to clarify semantics, update
its signature and all internal call sites (calls that currently use
triplestore.AdjacentEdges) to use AdjacentEdgeIDs, and keep the implementation
using adjacentEdgeIndices and s.edges[].ID unchanged so callers continue to
receive []uint64 IDs.
- Around line 109-125: NumNodes currently calls nodeBitmap which allocates and
populates a Bitmap64 on every call; replace this with a direct union-cardinality
computation over the existing map keys to avoid allocation. Modify NumNodes to
iterate startIndex and endIndex, insert keys into a temporary
map[uint64]struct{} (or maintain a dedicated node set on triplestore) and return
the length as uint64; then remove nodeBitmap if it has no other callers and
adjust EachNode (if it iterates nodes) to reuse the same key-union approach or
the new dedicated node set to keep behavior consistent. Ensure you reference and
update the functions/fields nodeBitmap, NumNodes, EachNode, startIndex, and
endIndex accordingly.
- Around line 137-153: EachNode currently creates a cardinality.Bitmap64 named
nodes to deduplicate node IDs; replace that with a plain map[uint64]struct{} for
membership checks to avoid the external bitmap allocation. In the EachNode
method, declare nodes := make(map[uint64]struct{}), use _, ok := nodes[nodeID]
to test presence and nodes[nodeID] = struct{}{} to insert when iterating
s.startIndex and s.endIndex, preserving the existing short-circuit return when
delegate(nodeID) is false; remove the cardinality usage/import once replaced.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3c2ed1e and f42231d.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (8)
  • algo/reach.go
  • cardinality/cardinality.go
  • cardinality/roaring32.go
  • cardinality/roaring64.go
  • container/triplestore.go
  • container/triplestore_test.go
  • go.mod
  • graph/types.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • graph/types.go
  • algo/reach.go
  • go.mod
  • cardinality/roaring64.go

Comment on lines 10 to +15
type Edge struct {
ID uint64
Start uint64
End uint64
ID uint64 `json:"id"`
Kind graph.Kind `json:"kind"`
Start uint64 `json:"start_id"`
End uint64 `json:"end_id"`
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

graph.Kind is an interface — JSON unmarshaling of Edge.Kind will fail at runtime.

json.Marshal will encode the concrete value's fields or String() if it implements json.Marshaler, but json.Unmarshal into an interface-typed field produces map[string]interface{}, not the original concrete graph.Kind. If Edge is ever round-tripped through JSON (e.g., stored, sent over the wire, or deserialized in tests), the Kind field will silently unmarshal to the wrong type.

If round-trip fidelity is required, either use a concrete type for Kind, add a custom UnmarshalJSON, or document the limitation.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 10 - 15, Edge.Kind is declared as the
interface graph.Kind which will marshal but not unmarshal correctly; fix this by
making Edge handle JSON explicitly: implement Edge.MarshalJSON and
Edge.UnmarshalJSON (or a wrapper type like EdgeJSON) to serialize Kind to a
stable representation (e.g., its String() or a type-tagged object) and to
reconstruct the concrete graph.Kind implementation on Unmarshal (using a type
switch, registry, or parse helper). Alternatively, make the Kind field a
concrete type instead of graph.Kind if a single implementation exists; update
the Edge struct and any callers accordingly so JSON round-trips preserve the
concrete Kind value.

Comment on lines +171 to 198
func (s *triplestore) adjacentEdgeIndices(node uint64, direction graph.Direction) []uint64 {
switch direction {
case graph.DirectionOutbound:
if outboundEdges, hasOutbound := s.startIndex[node]; hasOutbound {
edgeIndices.Or(outboundEdges)
return outboundEdges
}

case graph.DirectionInbound:
if inboundEdges, hasInbound := s.endIndex[node]; hasInbound {
edgeIndices.Or(inboundEdges)
return inboundEdges
}

default:
edgeIndices := cardinality.NewBitmap64()

if outboundEdges, hasOutbound := s.startIndex[node]; hasOutbound {
edgeIndices.Or(outboundEdges)
edgeIndices.Add(outboundEdges...)
}

if inboundEdges, hasInbound := s.endIndex[node]; hasInbound {
edgeIndices.Or(inboundEdges)
edgeIndices.Add(inboundEdges...)
}

return edgeIndices.Slice()
}

return edgeIndices
return nil
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

adjacentEdgeIndices returns indices in different orders depending on direction.

For DirectionOutbound/DirectionInbound, indices are returned in insertion order (direct slice from the map). For the default (both-directions) case, indices go through a Bitmap64 whose Slice() returns them in numerically sorted order. Callers using DirectionBoth will observe edge indices — and therefore edges — in a different order than they were inserted, which is inconsistent with the other two directions. If stable ordering matters across all direction values, sort the slice in the outbound/inbound paths as well, or document the ordering contract per direction.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 171 - 198, adjacentEdgeIndices
currently returns s.startIndex[node] or s.endIndex[node] directly for
DirectionOutbound/Inbound but returns a numerically-sorted slice from
Bitmap64.Slice() for the default (both) case, causing inconsistent ordering;
update adjacentEdgeIndices to sort the outbound/inbound slices before returning
(e.g., use sort.Slice or sort.Uint64s on the slice returned from
s.startIndex[node] and s.endIndex[node]) so all paths produce a consistent
numeric order, or alternatively add a clear comment documenting the differing
ordering contract if you prefer to keep insertion order for single-direction
calls.

Comment on lines 242 to 248
func (s *triplestore) EachAdjacentNode(node uint64, direction graph.Direction, delegate func(adjacent uint64) bool) {
s.adjacent(node, direction).Each(delegate)
for _, node := range s.adjacent(node, direction) {
if !delegate(node) {
break
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

node loop variable shadows the node parameter in EachAdjacentNode.

func (s *triplestore) EachAdjacentNode(node uint64, direction graph.Direction, delegate func(adjacent uint64) bool) {
    for _, node := range s.adjacent(node, direction) {

s.adjacent(node, direction) is evaluated correctly before the loop variable is set, so the runtime behaviour is currently fine. However, after the first iteration, the symbol node inside the loop body refers to the iteration value, not the parameter. This is a maintenance hazard — any future code added inside the loop that intends to reference the parameter will silently use the wrong value.

🐛 Proposed fix
 func (s *triplestore) EachAdjacentNode(node uint64, direction graph.Direction, delegate func(adjacent uint64) bool) {
-    for _, node := range s.adjacent(node, direction) {
-        if !delegate(node) {
-            break
-        }
-    }
+    for _, adjacentNode := range s.adjacent(node, direction) {
+        if !delegate(adjacentNode) {
+            break
+        }
+    }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@container/triplestore.go` around lines 242 - 248, The loop variable in
EachAdjacentNode shadows the parameter node; change the iteration variable name
used in the range over s.adjacent(node, direction) (e.g., to adj or adjacentID)
so it no longer hides the function parameter, and update uses inside the loop to
call delegate with that new variable name; keep the call to s.adjacent(node,
direction) and the delegate invocation semantics unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant