Repository: networkx/networkx

Issue:
cytoscape JSON is not valid
The cytoscape JSON exporter does not export valid JSON.


### Current Behavior

The `networkx.cytoscape_data` function serializes a dictionary into a string, using regular Python string serialization.

However, this uses:

- single quote for strings, 
- raw values for booleans, thus with an upper-case first letter,
- empty lists for null values.

All of which are invalid under JSON specification.


### Expected Behavior

The feature should use double quotes, lower-case booleans and `null` as a null value.


### Steps to Reproduce

```python
import networkx as nx
# From the doc:
data_dict = {
    "data": [],
    "directed": False,
    "multigraph": False,
    "elements": {
        "nodes": [
            {"data": {"id": "0", "value": 0, "name": "0"}},
            {"data": {"id": "1", "value": 1, "name": "1"}},
        ],
        "edges": [{"data": {"source": 0, "target": 1}}],
    },
}
G = nx.cytoscape_graph(data_dict)
with open("test.json") as fd:
    fd.write(str(nx.cytoscape_data(G)))
```

```sh
cat test.json | jq
# jq: parse error: Invalid numeric literal at line 1, column 8
# jq: parse error: Invalid numeric literal at line 1, column 30
# …
```

### Environment

Python version: 3.13
NetworkX version: 3.4.2

### Additional context

It should be sufficient to export with the `json` module instead of relying on Python's serialization.

Impacted line:
https://github.com/networkx/networkx/blob/7ccc55971dace7a50248b3de68d73632bf623e33/networkx/readwrite/json_graph/cytoscape.py#L80
may be replaced by:
```python
import json
# […]
return json.dumps(jsondata)
```

Probably the same should be done for import.

Discussion:

Patch:
diff --git a/networkx/algorithms/centrality/betweenness.py b/networkx/algorithms/centrality/betweenness.py
index 42e09771d..df8f21d10 100644
--- a/networkx/algorithms/centrality/betweenness.py
+++ b/networkx/algorithms/centrality/betweenness.py
@@ -127,6 +127,12 @@ def betweenness_centrality(
        https://doi.org/10.2307/3033543
     """
     betweenness = dict.fromkeys(G, 0.0)  # b[v]=0 for v in G
+    if k == len(G):
+        # This is for performance and correctness. When `endpoints` is False
+        # and k is given, k == n is a special case that would violate the
+        # assumption that node `v` is not one of the (s, t) node pairs.
+        # Should this warn or raise instead?
+        k = None
     if k is None:
         nodes = G
     else:
@@ -357,25 +363,31 @@ def _accumulate_edges(betweenness, S, P, sigma, s):
 
 
 def _rescale(betweenness, n, normalized, directed=False, k=None, endpoints=False):
+    # N is used to count the number of valid (s, t) pairs where s != t that
+    # could have a path pass through v. If endpoints is False, then v must
+    # not be the target t, hence why we subtract by 1.
+    N = n if endpoints else n - 1
+    if N < 2:
+        # No rescaling necessary: b=0 for all nodes
+        return betweenness
+
+    K_source = N if k is None else k
     if normalized:
-        if endpoints:
-            if n < 2:
-                scale = None  # no normalization
-            else:
-                # Scale factor should include endpoint nodes
-                scale = 1 / (n * (n - 1))
-        elif n <= 2:
-            scale = None  # no normalization b=0 for all nodes
-        else:
-            scale = 1 / ((n - 1) * (n - 2))
-    else:  # rescale by 2 for undirected graphs
+        # Divide by the number of valid (s, t) node pairs that could have
+        # a path through v where s != t.
+        scale = 1 / (K_source * (N - 1))
+    else:
         if not directed:
-            scale = 0.5
-        else:
-            scale = None
-    if scale is not None:
-        if k is not None:
-            scale = scale * n / k
+            # The non-normalized BC values are computed the same way for
+            # directed and undirected graphs: shortest paths are computed and
+            # counted for each *ordered* (s, t) pair. Undirected graphs should
+            # only count valid *unordered* node pairs {s, t}; that is, (s, t)
+            # and (t, s) should be counted only once. We correct for this here.
+            K_source *= 2
+        # Scale to the full BC
+        scale = N / K_source
+
+    if scale != 1:
         for v in betweenness:
             betweenness[v] *= scale
     return betweenness
diff --git a/networkx/algorithms/cycles.py b/networkx/algorithms/cycles.py
index 8fb11a15a..c9df0364c 100644
--- a/networkx/algorithms/cycles.py
+++ b/networkx/algorithms/cycles.py
@@ -585,11 +585,13 @@ def chordless_cycles(G, length_bound=None):
     # Nodes with loops cannot belong to longer cycles.  Let's delete them here.
     # also, we implicitly reduce the multiplicity of edges down to 1 in the case
     # of multiedges.
+    loops = set(nx.nodes_with_selfloops(G))
+    edges = ((u, v) for u in G if u not in loops for v in G._adj[u] if v not in loops)
     if directed:
-        F = nx.DiGraph((u, v) for u, Gu in G.adj.items() if u not in Gu for v in Gu)
+        F = nx.DiGraph(edges)
         B = F.to_undirected(as_view=False)
     else:
-        F = nx.Graph((u, v) for u, Gu in G.adj.items() if u not in Gu for v in Gu)
+        F = nx.Graph(edges)
         B = None
 
     # If we're given a multigraph, we have a few cases to consider with parallel
@@ -614,6 +616,8 @@ def chordless_cycles(G, length_bound=None):
             B = F.copy()
             visited = set()
         for u, Gu in G.adj.items():
+            if u in loops:
+                continue
             if directed:
                 multiplicity = ((v, len(Guv)) for v, Guv in Gu.items())
                 for v, m in multiplicity:
diff --git a/networkx/drawing/layout.py b/networkx/drawing/layout.py
index ea1f7767a..1cb27d6fd 100644
--- a/networkx/drawing/layout.py
+++ b/networkx/drawing/layout.py
@@ -1346,7 +1346,7 @@ def arf_layout(
         Scales the radius of the circular layout space.
 
     a : float
-        Strength of springs between connected nodes. Should be larger than 1. The greater a, the clearer the separation ofunconnected sub clusters.
+        Strength of springs between connected nodes. Should be larger than 1. The greater a, the clearer the separation of unconnected sub clusters.
 
     etol : float
         Gradient sum of spring forces must be larger than `etol` before successful termination.
diff --git a/networkx/readwrite/json_graph/cytoscape.py b/networkx/readwrite/json_graph/cytoscape.py
index 2f3b2176a..417e36f72 100644
--- a/networkx/readwrite/json_graph/cytoscape.py
+++ b/networkx/readwrite/json_graph/cytoscape.py
@@ -38,14 +38,26 @@ def cytoscape_data(G, name="name", ident="id"):
 
     Examples
     --------
+    >>> from pprint import pprint
     >>> G = nx.path_graph(2)
-    >>> nx.cytoscape_data(G)  # doctest: +SKIP
+    >>> cyto_data = nx.cytoscape_data(G)
+    >>> pprint(cyto_data, sort_dicts=False)
     {'data': [],
      'directed': False,
      'multigraph': False,
      'elements': {'nodes': [{'data': {'id': '0', 'value': 0, 'name': '0'}},
-       {'data': {'id': '1', 'value': 1, 'name': '1'}}],
-      'edges': [{'data': {'source': 0, 'target': 1}}]}}
+                            {'data': {'id': '1', 'value': 1, 'name': '1'}}],
+                  'edges': [{'data': {'source': 0, 'target': 1}}]}}
+
+    The :mod:`json` package can be used to serialize the resulting data
+
+    >>> import io, json
+    >>> with io.StringIO() as fh:  # replace io with `open(...)` to write to disk
+    ...     json.dump(cyto_data, fh)
+    ...     fh.seek(0)  # doctest: +SKIP
+    ...     print(fh.getvalue()[:64])  # View the first 64 characters
+    {"data": [], "directed": false, "multigraph": false, "elements":
+
     """
     if name == ident:
         raise nx.NetworkXError("name and ident must be different.")


Test patch:
diff --git a/networkx/algorithms/centrality/tests/test_betweenness_centrality.py b/networkx/algorithms/centrality/tests/test_betweenness_centrality.py
index 4c059cf98..d92338112 100644
--- a/networkx/algorithms/centrality/tests/test_betweenness_centrality.py
+++ b/networkx/algorithms/centrality/tests/test_betweenness_centrality.py
@@ -65,8 +65,8 @@ class TestBetweennessCentrality:
             assert b[n] == pytest.approx(b_answer[n], abs=1e-7)
         b = nx.betweenness_centrality(G, k=2, weight=None, normalized=False, seed=1)
         # python versions give different results with same seed
-        b_approx1 = {0: 0.0, 1: 1.5, 2: 0.0}
-        b_approx2 = {0: 0.0, 1: 0.75, 2: 0.0}
+        b_approx1 = {0: 0.0, 1: 1.0, 2: 0.0}
+        b_approx2 = {0: 0.0, 1: 0.5, 2: 0.0}
         for n in sorted(G):
             assert b[n] in (b_approx1[n], b_approx2[n])
 
@@ -288,6 +288,56 @@ class TestBetweennessCentrality:
         for n in sorted(G):
             assert b[n] == pytest.approx(b_answer[n], abs=1e-7)
 
+    @pytest.mark.parametrize(
+        ("normalized", "endpoints", "is_directed", "k", "expected"),
+        [
+            (True, True, True, None, {0: 1.0, 1: 0.4, 2: 0.4, 3: 0.4, 4: 0.4}),
+            (True, True, True, 1, {0: 1.0, 1: 1.0, 2: 0.25, 3: 0.25, 4: 0.25}),
+            (True, True, False, None, {0: 1.0, 1: 0.4, 2: 0.4, 3: 0.4, 4: 0.4}),
+            (True, True, False, 1, {0: 1.0, 1: 1.0, 2: 0.25, 3: 0.25, 4: 0.25}),
+            (True, False, True, None, {0: 1.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+            (True, False, True, 1, {0: 1.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+            (True, False, False, None, {0: 1.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+            (True, False, False, 1, {0: 1.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+            (False, True, True, None, {0: 20.0, 1: 8.0, 2: 8.0, 3: 8.0, 4: 8.0}),
+            (False, True, True, 1, {0: 20.0, 1: 20.0, 2: 5.0, 3: 5.0, 4: 5.0}),
+            (False, True, False, None, {0: 10.0, 1: 4.0, 2: 4.0, 3: 4.0, 4: 4.0}),
+            (False, True, False, 1, {0: 10.0, 1: 10.0, 2: 2.5, 3: 2.5, 4: 2.5}),
+            (False, False, True, None, {0: 12.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+            (False, False, True, 1, {0: 12.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+            (False, False, False, None, {0: 6.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+            (False, False, False, 1, {0: 6.0, 1: 0.0, 2: 0.0, 3: 0.0, 4: 0.0}),
+        ],
+    )
+    def test_scale_with_k_on_star_graph(
+        self, normalized, endpoints, is_directed, k, expected
+    ):
+        # seed=1 selects node 1 as the initial node when using k=1.
+        # Recall node 0 is the center of the star graph.
+        G = nx.star_graph(4)
+        if is_directed:
+            G = G.to_directed()
+        b = nx.betweenness_centrality(
+            G, k=k, seed=1, endpoints=endpoints, normalized=normalized
+        )
+        assert b == pytest.approx(expected)
+
+    def test_k_out_of_bounds_raises(self):
+        G = nx.cycle_graph(4)
+        with pytest.raises(ValueError, match="larger"):
+            nx.betweenness_centrality(G, k=5)
+        with pytest.raises(ValueError, match="negative"):
+            nx.betweenness_centrality(G, k=-1)
+        with pytest.raises(ZeroDivisionError):
+            nx.betweenness_centrality(G, k=0)
+        with pytest.raises(ZeroDivisionError):
+            nx.betweenness_centrality(G, k=0, normalized=False)
+        # Test edge case: use full population when k == len(G)
+        # Should we warn or raise instead?
+        b1 = nx.betweenness_centrality(G, k=4, endpoints=False)
+        b2 = nx.betweenness_centrality(G, endpoints=False)
+        assert b1 == b2
+
 
 class TestWeightedBetweennessCentrality:
     def test_K5(self):
diff --git a/networkx/algorithms/tests/test_cycles.py b/networkx/algorithms/tests/test_cycles.py
index dd21405ff..1b43929aa 100644
--- a/networkx/algorithms/tests/test_cycles.py
+++ b/networkx/algorithms/tests/test_cycles.py
@@ -1,6 +1,6 @@
+import random
 from itertools import chain, islice, tee
 from math import inf
-from random import shuffle
 
 import pytest
 
@@ -277,7 +277,8 @@ class TestCycleEnumeration:
         # enumeration algorithms
 
         relabel = list(range(len(g)))
-        shuffle(relabel)
+        rng = random.Random(42)
+        rng.shuffle(relabel)
         label = dict(zip(g, relabel))
         unlabel = dict(zip(relabel, g))
         h = nx.relabel_nodes(g, label, copy=True)
@@ -345,6 +346,15 @@ class TestCycleEnumeration:
         expected_cycles = [c for c in expected_cycles if len(c) < 2]
         self.check_cycle_algorithm(g, expected_cycles, chordless=True, length_bound=1)
 
+    def test_chordless_cycles_multigraph_self_loops(self):
+        G = nx.MultiGraph([(1, 1), (2, 2), (1, 2), (1, 2)])
+        expected_cycles = [[1], [2]]
+        self.check_cycle_algorithm(G, expected_cycles, chordless=True)
+
+        G.add_edges_from([(2, 3), (3, 4), (3, 4), (1, 3)])
+        expected_cycles = [[1], [2], [3, 4]]
+        self.check_cycle_algorithm(G, expected_cycles, chordless=True)
+
     def test_directed_chordless_cycle_undirected(self):
         g = nx.DiGraph([(1, 2), (2, 3), (3, 4), (4, 5), (5, 0), (5, 1), (0, 2)])
         expected_cycles = [(0, 2, 3, 4, 5), (1, 2, 3, 4, 5)]