Skip to content

Exp algo inclusion#70

Open
shahar-biron wants to merge 10 commits intomainfrom
exp-algo-inclusion
Open

Exp algo inclusion#70
shahar-biron wants to merge 10 commits intomainfrom
exp-algo-inclusion

Conversation

@shahar-biron
Copy link
Collaborator

@shahar-biron shahar-biron commented Feb 10, 2026

Louvain and Leiden algorithms with helper functions

Summary by CodeRabbit

  • New Features

    • Introduced experimental Louvain community detection for graph clustering.
  • Documentation

    • Added "Experimental" category and detailed docs for the Louvain algorithm with usage examples and notes.
  • Tests

    • Added integration tests validating Louvain discovers expected communities.
  • Chores

    • Improved CI test reliability and made build output ordering deterministic.

shahar-biron and others added 5 commits February 9, 2026 10:19
Include src/exp-algo modules in FLEX and add an integration test for flex.exp.louvain.

Co-Authored-By: Warp <agent@warp.dev>
Allow jest-coverage-report-action to write checks/PR comments on pull_request runs.

Co-Authored-By: Warp <agent@warp.dev>
jest-coverage-report-action defaults to parallel Jest execution, which flakes for the integration tests; run it with --runInBand.

Co-Authored-By: Warp <agent@warp.dev>
Document flex.exp.louvain and add an experimental warning in docs index and main README.

Co-Authored-By: Warp <agent@warp.dev>
@coderabbitai
Copy link

coderabbitai bot commented Feb 10, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds an experimental Louvain community-detection feature (exp.louvain) with traversal helpers (BFS/DFS), community utilities, tests, docs, and CI tweaks to run tests sequentially for stability.

Changes

Cohort / File(s) Summary
CI/CD
.github/workflows/test.yml
Grant writes for checks/pull-requests; run Jest coverage with --runInBand --forceExit to avoid flaky parallel integration tests.
Docs / Index
README.md, docs/README.md
Add "Experimental" category/section and an entry for exp.louvain (marked experimental).
API Docs
docs/exp-algo/louvain.md
New detailed documentation for exp.louvain: usage, params, returns, examples, and notes.
Traversal Utilities
src/exp-algo/filterBFS.js, src/exp-algo/filterDFS.js
Add generic edge-filtering BFS and DFS utilities with depth/visited limits, parent maps, and CommonJS exports.
Community Helpers
src/exp-algo/community.js
New helper library exposing many public utilities (edge weighting, ID helpers, adjacency builder, partition/communities helpers, oneLevel, inducedGraph, etc.) attached to global __flexExpAlgo and conditionally to CommonJS.
Louvain Implementation
src/exp-algo/louvain.js
New multi-level Louvain implementation using adjacency builder, iterative oneLevel passes, induced-graph coarsening, renumbering, and registration as exp.louvain; CommonJS exports when available.
Tests
tests/exp-algo/louvain.test.js
Integration test verifying exp.louvain finds three communities in a weighted graph of three teams.
Build Determinism
build.js
Make file traversal deterministic by sorting files and results to produce stable bundle ordering.

Sequence Diagram(s)

sequenceDiagram
    participant Test as Test / Client
    participant Louvain as exp.louvain
    participant BuildAdj as buildUndirectedAdjacency
    participant Graph as graph.traverse
    participant OneLvl as oneLevel
    participant InducedG as inducedGraph

    Test->>Louvain: invoke(nodes, direction, ...)
    Louvain->>BuildAdj: build adjacency (nodes, direction, getWeight)
    BuildAdj->>Graph: traverse node edges
    Graph-->>BuildAdj: edges
    BuildAdj-->>Louvain: adjacency, nodeIds

    loop per level (<= maxLevels)
        Louvain->>OneLvl: optimize partition (adjacency)
        OneLvl-->>Louvain: partition, moved
        alt moved
            Louvain->>InducedG: create coarsened graph from partition
            InducedG->>Graph: (traverse/aggregate edges as needed)
            Graph-->>InducedG: aggregated edges
            InducedG-->>Louvain: new adjacency
        else no moves
            Louvain-->>Louvain: stop iterations
        end
    end

    Louvain->>Louvain: renumber partition -> communities
    Louvain-->>Test: return { partition, communities, levels }
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐇 I hopped through nodes and edges tonight,
Turned weights and walks into groups so tight.
Louvain hummed, communities grew,
A rabbit's small algorithmic woo! 🌿✨

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Exp algo inclusion' is vague and generic, using abbreviations ('Exp') and non-descriptive terms ('inclusion') that don't clearly convey what is being added to the codebase. Use a more descriptive title that clearly indicates the main change, such as 'Add Louvain community detection algorithm' or 'Implement experimental graph algorithms with Louvain clustering'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch exp-algo-inclusion

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.

@shahar-biron shahar-biron requested a review from AviAvni February 10, 2026 07:07
@github-actions
Copy link

github-actions bot commented Feb 10, 2026

Coverage report

St.
Category Percentage Covered / Total
🔴 Statements
51.05% (-37.41% 🔻)
437/856
🔴 Branches
35.99% (-36.95% 🔻)
199/553
🟡 Functions
70.24% (-29.76% 🔻)
59/84
🔴 Lines
52.89% (-40.1% 🔻)
394/745
Show new covered files 🐣
St.
File Statements Branches Functions Lines
🔴 exp-algo/louvain.js 14.29% 11.11% 33.33% 15.15%
🔴
... / community.js
6.57% 1.51% 10.53% 7.38%
🔴
... / filterBFS.js
0% 0% 0% 0%
🔴
... / filterDFS.js
0% 0% 0% 0%

Test suite run success

79 tests passing in 27 suites.

Report generated by 🧪jest coverage report action from 29437e9

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: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.github/workflows/test.yml (1)

33-54: ⚠️ Potential issue | 🟡 Minor

Inconsistent parallelism between the two test steps.

Line 36 runs npm test -- --coverage (parallel by default), while Line 54 runs npx jest --runInBand --forceExit. If integration tests are unsafe to run in parallel (as the comment on Line 51-53 states), the first "Run Tests with Coverage" step on Line 36 has the same flakiness risk.

Consider adding --runInBand --forceExit to Line 36 as well, or extracting the test command into a shared npm script so both steps stay consistent.

Proposed fix
       - name: Run Tests with Coverage
         # Because coverageThreshold is in jest.config.js, 
         # this step will fail if coverage is < 90%
-        run: npm test -- --coverage 
+        run: npx jest --runInBand --forceExit --coverage
         env:
           DEBUG: "true"
🤖 Fix all issues with AI agents
In `@src/exp-algo/filterBFS.js`:
- Around line 52-54: The break condition in the BFS loop allows one extra
visited node because it uses "visited.size > maxVisited"; change this to
"visited.size >= maxVisited" inside the loop that contains currentLevel/depth
checks (the BFS function using variables currentLevel, depth, visited,
maxVisited) so the traversal stops once visited reaches the intended upper
bound; ensure any tests or documentation that state maxVisited as a strict cap
reflect this semantics.
- Line 32: The default getNeighbor in filterBFS.js (and the analogous one in
filterDFS.js) always returns edge.source and ignores the current node, causing
traversal to return to the same node; update getNeighbor(edge, current) to check
edge and current and return the opposite endpoint (if edge.source.id ===
current.id return edge.target, else return edge.source), handling missing
IDs/undefined edge safely — follow the pattern used in community.js to compare
node IDs and return the other endpoint so BFS/DFS advances to the real neighbor.

In `@src/exp-algo/leiden.js`:
- Around line 37-44: The seeding logic in makeSeededRandom currently uses "(seed
>>> 0) || 1" which maps an explicit seed of 0 to 1; change it so only
absent/undefined seeds default to 1 while preserving an explicit 0. Concretely,
in makeSeededRandom set s = (seed === undefined || seed === null) ? 1 : (seed
>>> 0) (so an explicit 0 stays 0), leaving the LCG body (s = (1664525 * s +
1013904223) >>> 0; return s / 4294967296;) unchanged; reference function
makeSeededRandom and variable s.
- Around line 76-92: The comment says "BFS" but the code uses q.pop(), which
implements DFS; either change the comment to reflect DFS or change q.pop() to
q.shift() to perform a true BFS; locate the loop using q, q.pop(), visited,
refined and adjacency in the component expansion logic and update the comment to
"DFS" if you want to keep the current stack-based traversal or replace pop()
with shift() (and optionally adjust any performance expectations) to implement
an actual BFS.

In `@src/exp-algo/louvain.js`:
- Around line 38-65: The JSDoc for the louvain function is missing the
documented `@param` for the debug option; update the comment block above function
louvain to add a line like "@param {boolean} [params.debug=false] Enable debug
logging/tracing" (or similar) so the debug parameter is documented; ensure the
param name matches the function signature (debug) and place it with the other
`@param` entries in the JSDoc for louvain.
🧹 Nitpick comments (9)
docs/exp-algo/louvain.md (1)

92-93: Add cross-reference to exp.leiden in See Also.

leiden.md links back to louvain.md, but this page doesn't link to Leiden. Adding the cross-reference keeps navigation symmetric.

Proposed fix
 ## See Also
+- [exp.leiden](./leiden.md)
 - [docs/README.md](../README.md)
docs/exp-algo/leiden.md (1)

17-30: Markdownlint: add blank line before the parameters table.

Per MD058, tables should be surrounded by blank lines. There's a missing blank line between the ## Parameters heading and the table.

Proposed fix
 ## Parameters
+
 | Parameter | Type | Required | Description |
tests/exp-algo/louvain.test.js (2)

27-136: Consider extracting the shared test scaffold with leiden.test.js.

The graph-creation Cypher (lines 36-75), the idToName mapping (lines 77-87), the community-size assertions (lines 102-117), the sameSet helper (lines 123-127), and the team-membership checks (lines 119-135) are nearly identical to the Leiden test. This makes future maintenance harder — any graph topology change must be mirrored in both files.

Consider extracting the shared parts (graph seeding, idToName builder, sameSet, team definitions and assertions) into a common test helper, keeping only the algorithm-specific Cypher call in each test file.


96-97: No defensive check before accessing out.data[0].res.

If the query returns no rows (e.g. graph setup failed silently), out.data[0] will throw a TypeError. A guard or a preceding expect(out.data.length).toBeGreaterThan(0) would make failures easier to diagnose.

Proposed improvement
     const out = await graph.query(resQ);
+    expect(out.data).toBeDefined();
+    expect(out.data.length).toBeGreaterThan(0);
     const res = out.data[0].res;
tests/exp-algo/leiden.test.js (1)

120-121: Same defensive-check gap as the Louvain test.

out.data[0].res is accessed without guarding against empty results. Same suggestion applies: add an expect(out.data.length).toBeGreaterThan(0) before dereferencing.

src/exp-algo/louvain.js (1)

140-148: Inconsistent module.exports surface between Louvain and Leiden.

Louvain re-exports shared community.js utilities (getEdgeWeight, buildUndirectedAdjacency, oneLevel, inducedGraph) alongside louvain, while Leiden only exports refineByConnectedComponents and leiden. If a consumer imports from louvain.js expecting these helpers, they'd be surprised they're absent from leiden.js (and vice versa).

Consider either making both modules export only their own algorithm (plus any module-local functions like refineByConnectedComponents), or documenting that community.js is the canonical import path for shared utilities.

src/exp-algo/community.js (3)

62-66: Edge-key separator :: can collide with node IDs that contain ::, breaking adjacency reconstruction.

makeUndirectedEdgeKey (line 65) joins IDs with ::, and buildUndirectedAdjacency (line 252) calls key.split('::') to recover endpoints. If a node ID ever contains the substring ::, the split produces more than two parts and the wrong IDs are looked up.

In practice FalkorDB node IDs are integers, so this is safe today, but it's fragile if IDs become user-controlled strings.

A simple fix is to limit the split:

Safer split
-      const parts = key.split('::');
-      const aStr = parts[0];
-      const bStr = parts[1];
+      const sepIdx = key.indexOf('::');
+      const aStr = key.slice(0, sepIdx);
+      const bStr = key.slice(sepIdx + 2);

This still won't handle IDs containing :: in makeUndirectedEdgeKey's lexicographic ordering, but at least the split is correct for a single separator occurrence. A fully robust approach would use a delimiter that cannot appear in stringified IDs, or store endpoints in a Map keyed by a composite tuple.

Also applies to: 251-264


22-27: defineIfMissing always assigns — name is misleading.

The comment on lines 23-26 explains the rationale (reload safety, order-independence), but the function name implies conditional assignment. A name like define or register would be more accurate.


140-285: buildUndirectedAdjacency — solid implementation with one assumption worth documenting.

Line 180: const edges = reachables[0] || [] assumes graph.traverse returns an array-of-arrays where the first element is the edge list. This is a FalkorDB-specific convention. Consider adding a brief inline comment clarifying this contract for future maintainers.

Comment on lines +52 to +54
while (currentLevel.length > 0) {
if (depth >= maxDepth) break;
if (visited.size > maxVisited) 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

maxVisited check uses > — allows maxVisited + 1 entries.

visited is initialized with startId (size 1), and the break condition is visited.size > maxVisited. If maxVisited = 5, this breaks at size 6. If the intent is to cap at exactly maxVisited nodes, use >=.

This is a minor semantic question — just verify the intended behavior matches the documentation (maxVisited as an upper bound).

🤖 Prompt for AI Agents
In `@src/exp-algo/filterBFS.js` around lines 52 - 54, The break condition in the
BFS loop allows one extra visited node because it uses "visited.size >
maxVisited"; change this to "visited.size >= maxVisited" inside the loop that
contains currentLevel/depth checks (the BFS function using variables
currentLevel, depth, visited, maxVisited) so the traversal stops once visited
reaches the intended upper bound; ensure any tests or documentation that state
maxVisited as a strict cap reflect this semantics.

Comment on lines +37 to +44
function makeSeededRandom(seed) {
// Simple LCG (deterministic).
let s = (seed >>> 0) || 1;
return function random() {
s = (1664525 * s + 1013904223) >>> 0;
return s / 4294967296;
};
}
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

seed = 0 silently maps to seed = 1, making them indistinguishable.

(seed >>> 0) || 1 converts 0 to 1. If a caller explicitly passes seed: 0 for reproducibility, they'd unknowingly get the same sequence as seed: 1.

Suggested fix
-    let s = (seed >>> 0) || 1;
+    let s = seed === 0 ? 0 : (seed >>> 0) || 1;

Note: an LCG starting at state 0 with additive constant 1013904223 (odd) will still cycle through all 2³² states, so s = 0 is a valid starting state for this particular LCG.

🧰 Tools
🪛 GitHub Check: Coverage annotations (🧪 jest-coverage-report-action)

[warning] 39-39: 🧾 Statement is not covered
Warning! Not covered statement


[warning] 40-43: 🧾 Statement is not covered
Warning! Not covered statement


[warning] 41-41: 🧾 Statement is not covered
Warning! Not covered statement


[warning] 42-42: 🧾 Statement is not covered
Warning! Not covered statement

🤖 Prompt for AI Agents
In `@src/exp-algo/leiden.js` around lines 37 - 44, The seeding logic in
makeSeededRandom currently uses "(seed >>> 0) || 1" which maps an explicit seed
of 0 to 1; change it so only absent/undefined seeds default to 1 while
preserving an explicit 0. Concretely, in makeSeededRandom set s = (seed ===
undefined || seed === null) ? 1 : (seed >>> 0) (so an explicit 0 stays 0),
leaving the LCG body (s = (1664525 * s + 1013904223) >>> 0; return s /
4294967296;) unchanged; reference function makeSeededRandom and variable s.

Comment on lines +76 to +92
// BFS
const q = [start];
visited.add(start);
refined.set(start, compKey);

while (q.length) {
const v = q.pop();
const neigh = adjacency.get(v);
if (!neigh) continue;

for (const nbr of neigh.keys()) {
if (!inComm.has(nbr)) continue;
if (visited.has(nbr)) continue;
visited.add(nbr);
refined.set(nbr, compKey);
q.push(nbr);
}
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

Comment says "BFS" but q.pop() is DFS (stack).

Using pop() makes this a depth-first traversal. Both DFS and BFS correctly find connected components, so there's no functional bug, but the comment is misleading.

Fix: either update the comment or switch to shift()
-        // BFS
         const q = [start];
+        // DFS (stack) – any traversal order finds the same components.
         visited.add(start);

Or use q.shift() for actual BFS (though DFS is fine and faster here without a proper queue).

🧰 Tools
🪛 GitHub Check: Coverage annotations (🧪 jest-coverage-report-action)

[warning] 84-84: 🧾 Statement is not covered
Warning! Not covered statement


[warning] 87-87: 🧾 Statement is not covered
Warning! Not covered statement

🤖 Prompt for AI Agents
In `@src/exp-algo/leiden.js` around lines 76 - 92, The comment says "BFS" but the
code uses q.pop(), which implements DFS; either change the comment to reflect
DFS or change q.pop() to q.shift() to perform a true BFS; locate the loop using
q, q.pop(), visited, refined and adjacency in the component expansion logic and
update the comment to "DFS" if you want to keep the current stack-based
traversal or replace pop() with shift() (and optionally adjust any performance
expectations) to implement an actual BFS.

Comment on lines +38 to +65
/**
* Louvain algorithm.
*
* @param {object} params
* @param {any[]} params.nodes Input nodes (objects), must include `id` by default.
* @param {string|string[]} [params.direction='both'] Traversal direction(s) used to build adjacency.
* @param {number} [params.maxEdgesPerNode=Infinity] Safety cap per node traversal.
* @param {number} [params.resolution=1] Modularity resolution (gamma).
* @param {number} [params.maxPasses=10] Max passes per level.
* @param {number} [params.maxLevels=10] Max coarsening levels.
* @param {number} [params.minGain=1e-12] Minimum gain threshold to accept a move.
* @param {(node:any)=>any} [params.getNodeId]
* @param {(edge:any)=>number} [params.getWeight]
*
* @returns {{ partition: Object, communities: Object, levels: number }}
*/
function louvain({
nodes,
direction = 'both',
maxEdgesPerNode = Infinity,
resolution = 1,
maxPasses = 10,
maxLevels = 10,
minGain = 1e-12,
getNodeId = exp.defaultGetNodeId,
getWeight = (edge) => exp.getEdgeWeight(edge),
debug = false,
}) {
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

debug parameter is undocumented in JSDoc.

Line 64 accepts debug = false but the JSDoc block (lines 38-53) doesn't mention it. This matters for consumers of the public API.

Add the missing `@param`
    * `@param` {(edge:any)=>number} [params.getWeight]
-   *
+   * `@param` {boolean} [params.debug=false] Emit debug data in the result.
    * `@returns` {{ partition: Object, communities: Object, levels: number }}
📝 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
/**
* Louvain algorithm.
*
* @param {object} params
* @param {any[]} params.nodes Input nodes (objects), must include `id` by default.
* @param {string|string[]} [params.direction='both'] Traversal direction(s) used to build adjacency.
* @param {number} [params.maxEdgesPerNode=Infinity] Safety cap per node traversal.
* @param {number} [params.resolution=1] Modularity resolution (gamma).
* @param {number} [params.maxPasses=10] Max passes per level.
* @param {number} [params.maxLevels=10] Max coarsening levels.
* @param {number} [params.minGain=1e-12] Minimum gain threshold to accept a move.
* @param {(node:any)=>any} [params.getNodeId]
* @param {(edge:any)=>number} [params.getWeight]
*
* @returns {{ partition: Object, communities: Object, levels: number }}
*/
function louvain({
nodes,
direction = 'both',
maxEdgesPerNode = Infinity,
resolution = 1,
maxPasses = 10,
maxLevels = 10,
minGain = 1e-12,
getNodeId = exp.defaultGetNodeId,
getWeight = (edge) => exp.getEdgeWeight(edge),
debug = false,
}) {
/**
* Louvain algorithm.
*
* `@param` {object} params
* `@param` {any[]} params.nodes Input nodes (objects), must include `id` by default.
* `@param` {string|string[]} [params.direction='both'] Traversal direction(s) used to build adjacency.
* `@param` {number} [params.maxEdgesPerNode=Infinity] Safety cap per node traversal.
* `@param` {number} [params.resolution=1] Modularity resolution (gamma).
* `@param` {number} [params.maxPasses=10] Max passes per level.
* `@param` {number} [params.maxLevels=10] Max coarsening levels.
* `@param` {number} [params.minGain=1e-12] Minimum gain threshold to accept a move.
* `@param` {(node:any)=>any} [params.getNodeId]
* `@param` {(edge:any)=>number} [params.getWeight]
* `@param` {boolean} [params.debug=false] Emit debug data in the result.
* `@returns` {{ partition: Object, communities: Object, levels: number }}
*/
function louvain({
nodes,
direction = 'both',
maxEdgesPerNode = Infinity,
resolution = 1,
maxPasses = 10,
maxLevels = 10,
minGain = 1e-12,
getNodeId = exp.defaultGetNodeId,
getWeight = (edge) => exp.getEdgeWeight(edge),
debug = false,
}) {
🤖 Prompt for AI Agents
In `@src/exp-algo/louvain.js` around lines 38 - 65, The JSDoc for the louvain
function is missing the documented `@param` for the debug option; update the
comment block above function louvain to add a line like "@param {boolean}
[params.debug=false] Enable debug logging/tracing" (or similar) so the debug
parameter is documented; ensure the param name matches the function signature
(debug) and place it with the other `@param` entries in the JSDoc for louvain.

Co-Authored-By: Warp <agent@warp.dev>
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: 1

🤖 Fix all issues with AI agents
In `@docs/exp-algo/degreeCentrality.md`:
- Around line 19-26: Insert a blank line above the Parameters table to satisfy
the MD058 lint rule, and update the `maxEdgesPerNode` parameter type from
`integer` to `number` (since its default is `Infinity`) in the
degreeCentrality.md parameters table so the signature for `maxEdgesPerNode`
accurately reflects allowed values; leave descriptions and defaults (Infinity)
unchanged.
🧹 Nitpick comments (3)
src/exp-algo/degreeCentrality.js (2)

36-47: Missing input validation for nodes parameter.

If nodes is undefined, null, or not an array, the error will propagate from buildUndirectedAdjacency with an unclear message. A guard at the entry point of degreeCentrality would produce a more actionable error for callers.

🛡️ Suggested validation
   function degreeCentrality({
     nodes,
     direction = 'both',
     maxEdgesPerNode = Infinity,
     normalized = true,
     getNodeId = exp.defaultGetNodeId,
     getWeight = (edge) => exp.getEdgeWeight(edge),
     debug = false,
   }) {
+    if (!Array.isArray(nodes) || nodes.length === 0) {
+      throw new TypeError('exp.degreeCentrality: "nodes" must be a non-empty array');
+    }
     if (typeof exp.buildUndirectedAdjacency !== 'function') {

62-87: Minor: normalizedById is allocated unconditionally.

normalizedById is created on Line 64 even when normalized is false, though it will remain empty and unused. This is negligible for typical graph sizes but could be tightened for consistency.

tests/exp-algo/degreeCentrality.test.js (1)

8-25: Consider adding an edge-case test for a single-node or empty graph.

The normalized computation has a special case for n <= 1 (returns 0). A test with a single isolated node would exercise that branch and confirm no division-by-zero occurs.

Comment on lines +19 to +26
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `nodes` | `list<node>` | Yes | The set of nodes to analyze. Typically provided via `collect(n)`. |
| `direction` | `string \| list<string>` | No | Which traversal direction(s) to use when building adjacency. Common values: `'incoming'`, `'outgoing'`, `'both'`. Default: `'both'`. |
| `maxEdgesPerNode` | `integer` | No | Safety cap on how many edges to scan per node when building adjacency. Default: `Infinity`. |
| `normalized` | `boolean` | No | When `true`, also returns normalized degree (divide by `n-1`). Default: `true`. |
| `debug` | `boolean` | No | When `true`, returns additional debug information about adjacency building. Default: `false`. |
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

Add blank line before the parameters table (MD058).

The markdown linter flags a missing blank line before the table on Line 20. Also, maxEdgesPerNode is typed as integer but defaults to Infinity — consider updating the type to number for accuracy.

Suggested fix
 ## Parameters
+
 | Parameter | Type | Required | Description |
 |-----------|------|----------|-------------|
 | `nodes` | `list<node>` | Yes | The set of nodes to analyze. Typically provided via `collect(n)`. |
 | `direction` | `string \| list<string>` | No | Which traversal direction(s) to use when building adjacency. Common values: `'incoming'`, `'outgoing'`, `'both'`. Default: `'both'`. |
-| `maxEdgesPerNode` | `integer` | No | Safety cap on how many edges to scan per node when building adjacency. Default: `Infinity`. |
+| `maxEdgesPerNode` | `number` | No | Safety cap on how many edges to scan per node when building adjacency. Default: `Infinity`. |
📝 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
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `nodes` | `list<node>` | Yes | The set of nodes to analyze. Typically provided via `collect(n)`. |
| `direction` | `string \| list<string>` | No | Which traversal direction(s) to use when building adjacency. Common values: `'incoming'`, `'outgoing'`, `'both'`. Default: `'both'`. |
| `maxEdgesPerNode` | `integer` | No | Safety cap on how many edges to scan per node when building adjacency. Default: `Infinity`. |
| `normalized` | `boolean` | No | When `true`, also returns normalized degree (divide by `n-1`). Default: `true`. |
| `debug` | `boolean` | No | When `true`, returns additional debug information about adjacency building. Default: `false`. |
## Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `nodes` | `list<node>` | Yes | The set of nodes to analyze. Typically provided via `collect(n)`. |
| `direction` | `string \| list<string>` | No | Which traversal direction(s) to use when building adjacency. Common values: `'incoming'`, `'outgoing'`, `'both'`. Default: `'both'`. |
| `maxEdgesPerNode` | `number` | No | Safety cap on how many edges to scan per node when building adjacency. Default: `Infinity`. |
| `normalized` | `boolean` | No | When `true`, also returns normalized degree (divide by `n-1`). Default: `true`. |
| `debug` | `boolean` | No | When `true`, returns additional debug information about adjacency building. Default: `false`. |
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 20-20: Tables should be surrounded by blank lines

(MD058, blanks-around-tables)

🤖 Prompt for AI Agents
In `@docs/exp-algo/degreeCentrality.md` around lines 19 - 26, Insert a blank line
above the Parameters table to satisfy the MD058 lint rule, and update the
`maxEdgesPerNode` parameter type from `integer` to `number` (since its default
is `Infinity`) in the degreeCentrality.md parameters table so the signature for
`maxEdgesPerNode` accurately reflects allowed values; leave descriptions and
defaults (Infinity) unchanged.

shahar-biron and others added 2 commits February 10, 2026 10:13
Co-Authored-By: Warp <agent@warp.dev>
shahar-biron and others added 2 commits February 10, 2026 17:14
Co-Authored-By: Warp <agent@warp.dev>
Co-Authored-By: Warp <agent@warp.dev>
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

🤖 Fix all issues with AI agents
In `@src/exp-algo/community.js`:
- Around line 112-123: The error thrown in traverseEdgesForNode currently
references the wrong function name ("buildUndirectedAdjacency"); update the
thrown TypeError in traverseEdgesForNode to use the correct function name or a
generic message (e.g., "traverseEdgesForNode: global `graph.traverse` is not
available" or "global `graph.traverse` is not available") so the message matches
the function and aids debugging; modify the string in the TypeError thrown
inside traverseEdgesForNode accordingly.
- Around line 412-435: The tot map is updated incorrectly when a candidate
community has positive gain but ≤ minGain: after evaluating neighComms you
subtract nodeDegree from currentComm (tot.set(currentComm,...)) but always add
nodeDegree back to bestComm, which may differ from currentComm when the move was
rejected; fix by adding nodeDegree back to the community the node actually
remains in — use partition.get(nodeId) or re-check whether bestComm was assigned
as the final community and call tot.set(finalComm, (tot.get(finalComm)||0) +
nodeDegree) instead of unconditionally tot.set(bestComm,...); update the block
around symbols tot, partition, currentComm, bestComm, nodeId, nodeDegree,
minGain and movedThisPass so tot is restored to the correct community whenever a
move is rejected or accepted.
- Around line 49-53: The current stableKeyPart function converts node ids to
String(x) causing collisions between values like 1 and "1"; update stableKeyPart
to preserve type information in the produced key (for example incorporate typeof
x into the returned key or otherwise serialize type+value) so numeric and string
ids remain distinct; ensure the change is applied to the define('stableKeyPart',
function stableKeyPart(x) { ... }) implementation and adjust any consumers
expecting the old string-only key format if necessary.
🧹 Nitpick comments (3)
build.js (1)

22-23: The outer sort on Line 39-40 makes the inner sort on Line 23 redundant.

The final .sort() on allFiles by relative path already guarantees deterministic ordering of the complete list. The per-directory sort inside getAllFiles is therefore unnecessary — though harmless, it adds a small amount of overhead on every recursive call.

Not blocking; keeping both is a valid "belt and suspenders" approach.

Also applies to: 39-40

src/exp-algo/community.js (2)

62-70: Silent fallback when neither endpoint matches currentId.

Line 70 returns s (source) when both endpoints are valid but neither matches currentId. This silently picks an arbitrary neighbor rather than signaling a logic error. Consider logging or returning null to surface misuse.


176-243: Potential double-counting of edge weights when direction = 'both'.

With the default direction = 'both', normalizeTraverseDirections expands to ['incoming', 'outgoing']. For a directed edge A→B, traversing outgoing from A yields the edge, and traversing incoming to B also yields the same edge. Since addUndirected accumulates weights, the canonical pair (A, B) gets 2w instead of w.

This is likely benign for Louvain because all weights and degrees inflate uniformly, preserving the relative modularity-gain ordering. However, it means debug.totalUndirectedWeight will report inflated values, and callers relying on absolute weight semantics (e.g., a future weighted-cut metric) will get incorrect results. Worth documenting this behavior or deduplicating edges.

Comment on lines +49 to +53
define('stableKeyPart', function stableKeyPart(x) {
// Stable ordering for undirected edge keys.
// Note: this can collide for ids like 1 and "1".
return String(x);
});
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

🧩 Analysis chain

🏁 Script executed:

# Search for node ID patterns and usage
rg -n 'getNodeId|node\.id|nodeId' --type=js -C2 -g '!node_modules/**'

Repository: FalkorDB/flex

Length of output: 12935


🏁 Script executed:

# Also check the louvain.js file directly to understand node ID sources
rg -n 'nodeId|node\.id' src/exp-algo/louvain.js -C3

Repository: FalkorDB/flex

Length of output: 1013


🏁 Script executed:

# Look for test files that might show example node IDs
fd -e test.js -e spec.js | head -20

Repository: FalkorDB/flex

Length of output: 647


🏁 Script executed:

cat -n tests/exp-algo/louvain.test.js | head -100

Repository: FalkorDB/flex

Length of output: 3949


🏁 Script executed:

# Also check if there are any examples with mixed numeric and string node IDs
rg -A5 -B5 'getNodeId' tests/exp-algo/louvain.test.js | head -50

Repository: FalkorDB/flex

Length of output: 39


🏁 Script executed:

# Check if there's any type validation or coercion documented
rg -n 'node.*id|nodeId' src/exp-algo/community.js -B2 -A2 | grep -E "(type|typeof|number|string|warning|invariant)" | head -20

Repository: FalkorDB/flex

Length of output: 222


🏁 Script executed:

# Look at the defaultGetNodeId implementation more closely
sed -n '42,47p' src/exp-algo/community.js

Repository: FalkorDB/flex

Length of output: 261


🏁 Script executed:

# Check if there are any comments about mixed types or invariants
rg -n 'invariant|assume|consistent|type.*id' src/exp-algo/ -i

Repository: FalkorDB/flex

Length of output: 824


Fix collision handling in stableKeyPart for mixed-type node IDs.

The stableKeyPart function converts all node IDs to strings, which causes numeric 1 and string "1" to collide and silently merge as distinct nodes. Since defaultGetNodeId explicitly accepts both number and string types, and the function signature is generic ((node:any)=>any), mixed-type node IDs are permissible. Either enforce type consistency in node IDs or use a deduping strategy that respects type (e.g., include typeof id in the stable key).

🤖 Prompt for AI Agents
In `@src/exp-algo/community.js` around lines 49 - 53, The current stableKeyPart
function converts node ids to String(x) causing collisions between values like 1
and "1"; update stableKeyPart to preserve type information in the produced key
(for example incorporate typeof x into the returned key or otherwise serialize
type+value) so numeric and string ids remain distinct; ensure the change is
applied to the define('stableKeyPart', function stableKeyPart(x) { ... })
implementation and adjust any consumers expecting the old string-only key format
if necessary.

Comment on lines +112 to +123
define('traverseEdgesForNode', function traverseEdgesForNode(node, dir) {
if (typeof graph === 'undefined' || !graph || typeof graph.traverse !== 'function') {
throw new TypeError('buildUndirectedAdjacency: global `graph.traverse` is not available');
}

return (
graph.traverse([node], {
direction: dir,
returnType: 'edges',
}) || []
);
});
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

Error message references wrong function name.

Line 114 says 'buildUndirectedAdjacency: global \graph.traverse` is not available'but this is thetraverseEdgesForNode` function. The error message should reference the correct function name (or be generic) to avoid confusion during debugging.

Proposed fix
-      throw new TypeError('buildUndirectedAdjacency: global `graph.traverse` is not available');
+      throw new TypeError('traverseEdgesForNode: global `graph.traverse` is not available');
🤖 Prompt for AI Agents
In `@src/exp-algo/community.js` around lines 112 - 123, The error thrown in
traverseEdgesForNode currently references the wrong function name
("buildUndirectedAdjacency"); update the thrown TypeError in
traverseEdgesForNode to use the correct function name or a generic message
(e.g., "traverseEdgesForNode: global `graph.traverse` is not available" or
"global `graph.traverse` is not available") so the message matches the function
and aids debugging; modify the string in the TypeError thrown inside
traverseEdgesForNode accordingly.

Comment on lines +412 to +435
tot.set(currentComm, (tot.get(currentComm) || 0) - nodeDegree);

let bestComm = currentComm;
let bestGain = 0;

for (const [c, k_i_in] of neighComms.entries()) {
const totC = tot.get(c) || 0;
const gain = k_i_in - (resolution * totC * nodeDegree) / m2;
if (gain > bestGain) {
bestGain = gain;
bestComm = c;
}
}

if (bestComm !== currentComm && bestGain > minGain) {
partition.set(nodeId, bestComm);
movedThisPass = true;
movedAny = true;
moves += 1;
} else {
partition.set(nodeId, currentComm);
}

tot.set(bestComm, (tot.get(bestComm) || 0) + nodeDegree);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# First, let's read the relevant section of the community.js file
cat -n src/exp-algo/community.js | sed -n '400,445p'

Repository: FalkorDB/flex

Length of output: 1796


🏁 Script executed:

# Also check the function signature to understand the context
cat -n src/exp-algo/community.js | sed -n '340,360p'

Repository: FalkorDB/flex

Length of output: 638


🏁 Script executed:

# Find where oneLevel is called to understand minGain values
rg -n 'oneLevel|minGain' --type=js -B2 -A2

Repository: FalkorDB/flex

Length of output: 2454


Bug: tot map corruption when bestGain is positive but below the minGain threshold.

On Line 412, nodeDegree is subtracted from currentComm. The node is evaluated for moves by finding the best neighboring community (lines 414–424). If a better community is found with a gain above the minGain threshold (default 1e-12), the move is accepted. However, when a better community is found but its gain is positive yet ≤ minGain, the condition on Line 426 fails, the node remains in currentComm (Line 432), yet Line 435 incorrectly adds nodeDegree to bestComm instead of currentComm. This permanently transfers weight from the correct community to an incorrect one, corrupting the tot map and leading to progressively incorrect modularity-gain estimates in subsequent iterations.

🐛 Proposed fix: add nodeDegree back to the community the node actually ends up in
         if (bestComm !== currentComm && bestGain > minGain) {
           partition.set(nodeId, bestComm);
           movedThisPass = true;
           movedAny = true;
           moves += 1;
         } else {
+          bestComm = currentComm;
           partition.set(nodeId, currentComm);
         }
 
         tot.set(bestComm, (tot.get(bestComm) || 0) + nodeDegree);
🤖 Prompt for AI Agents
In `@src/exp-algo/community.js` around lines 412 - 435, The tot map is updated
incorrectly when a candidate community has positive gain but ≤ minGain: after
evaluating neighComms you subtract nodeDegree from currentComm
(tot.set(currentComm,...)) but always add nodeDegree back to bestComm, which may
differ from currentComm when the move was rejected; fix by adding nodeDegree
back to the community the node actually remains in — use partition.get(nodeId)
or re-check whether bestComm was assigned as the final community and call
tot.set(finalComm, (tot.get(finalComm)||0) + nodeDegree) instead of
unconditionally tot.set(bestComm,...); update the block around symbols tot,
partition, currentComm, bestComm, nodeId, nodeDegree, minGain and movedThisPass
so tot is restored to the correct community whenever a move is rejected or
accepted.

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