gephi-ai 1.18.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. gephi_ai-1.18.0/.gitignore +27 -0
  2. gephi_ai-1.18.0/PKG-INFO +89 -0
  3. gephi_ai-1.18.0/README.md +55 -0
  4. gephi_ai-1.18.0/bipartite.py +116 -0
  5. gephi_ai-1.18.0/caveats.json +189 -0
  6. gephi_ai-1.18.0/community_stability.py +120 -0
  7. gephi_ai-1.18.0/figure.py +375 -0
  8. gephi_ai-1.18.0/gephi_mcp.py +3139 -0
  9. gephi_ai-1.18.0/gephi_mcp_viewer/__init__.py +371 -0
  10. gephi_ai-1.18.0/gephi_mcp_viewer/assets/VENDORED.md +11 -0
  11. gephi_ai-1.18.0/gephi_mcp_viewer/assets/graphology.umd.min.js +2 -0
  12. gephi_ai-1.18.0/gephi_mcp_viewer/assets/sigma.min.js +1369 -0
  13. gephi_ai-1.18.0/gephi_mcp_viewer/community_layout.py +215 -0
  14. gephi_ai-1.18.0/gephi_mcp_viewer/profile.py +191 -0
  15. gephi_ai-1.18.0/gephi_mcp_viewer/similarity.py +116 -0
  16. gephi_ai-1.18.0/gephi_mcp_viewer/template.html +526 -0
  17. gephi_ai-1.18.0/graph_diff.py +105 -0
  18. gephi_ai-1.18.0/legend.py +133 -0
  19. gephi_ai-1.18.0/probe_verdicts.py +127 -0
  20. gephi_ai-1.18.0/pyproject.toml +92 -0
  21. gephi_ai-1.18.0/requirements.txt +4 -0
  22. gephi_ai-1.18.0/session_ledger.py +91 -0
  23. gephi_ai-1.18.0/stats_integrity.py +257 -0
  24. gephi_ai-1.18.0/tests/live_scale_test.py +133 -0
  25. gephi_ai-1.18.0/tests/live_smoke_test.py +422 -0
  26. gephi_ai-1.18.0/tests/probes/record.py +105 -0
  27. gephi_ai-1.18.0/tests/probes/test_probes.py +268 -0
  28. gephi_ai-1.18.0/tests/test_bipartite.py +136 -0
  29. gephi_ai-1.18.0/tests/test_centralization.py +95 -0
  30. gephi_ai-1.18.0/tests/test_claim_record.py +140 -0
  31. gephi_ai-1.18.0/tests/test_community_stability.py +140 -0
  32. gephi_ai-1.18.0/tests/test_community_stability_tool.py +183 -0
  33. gephi_ai-1.18.0/tests/test_edge_cases.py +313 -0
  34. gephi_ai-1.18.0/tests/test_enrichment.py +318 -0
  35. gephi_ai-1.18.0/tests/test_figure.py +188 -0
  36. gephi_ai-1.18.0/tests/test_filter_disclosure.py +140 -0
  37. gephi_ai-1.18.0/tests/test_graph_diff.py +127 -0
  38. gephi_ai-1.18.0/tests/test_group_c_tools.py +128 -0
  39. gephi_ai-1.18.0/tests/test_legend.py +121 -0
  40. gephi_ai-1.18.0/tests/test_legend_tool.py +229 -0
  41. gephi_ai-1.18.0/tests/test_probe_recording.py +149 -0
  42. gephi_ai-1.18.0/tests/test_register_is_shipped_pristine.py +76 -0
  43. gephi_ai-1.18.0/tests/test_repo_docs.py +159 -0
  44. gephi_ai-1.18.0/tests/test_session_ledger.py +135 -0
  45. gephi_ai-1.18.0/tests/test_stat_caveat_attachment.py +306 -0
  46. gephi_ai-1.18.0/tests/test_stats_integrity.py +220 -0
  47. gephi_ai-1.18.0/tests/test_text_network.py +495 -0
  48. gephi_ai-1.18.0/tests/test_tools.py +642 -0
  49. gephi_ai-1.18.0/tests/test_undo.py +296 -0
  50. gephi_ai-1.18.0/tests/test_viewer.py +680 -0
  51. gephi_ai-1.18.0/text_network.py +674 -0
  52. gephi_ai-1.18.0/uv.lock +1994 -0
@@ -0,0 +1,27 @@
1
+ gephi-ai-plugin/target/
2
+
3
+ # Personal Claude Code config (machine-specific hooks, not for other contributors)
4
+ .claude/settings.local.json
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.pyc
9
+ *.egg-info/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .mypy_cache/
13
+ .venv/
14
+ mcp-server/dist/
15
+
16
+ # MCPB build artifacts
17
+ mcpb/server/lib/
18
+ *.mcpb
19
+
20
+ # Local drafts and internal notes — never publish (LinkedIn posts, papers, scratch)
21
+ docs/
22
+ *-draft.md
23
+ *-draft.*
24
+
25
+ # Probe verdicts are per-install and must never ship or be committed
26
+ mcp-server/caveats.local.json
27
+ .artifact-verified
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: gephi-ai
3
+ Version: 1.18.0
4
+ Summary: MCP Server for controlling Gephi Desktop via remote API
5
+ Project-URL: Homepage, https://www.mattartz.me
6
+ Project-URL: Repository, https://github.com/MattArtzAnthro/gephi-ai
7
+ Author: Matt Artz
8
+ License: Apache-2.0
9
+ Keywords: claude,gephi,graph,mcp,network,visualization
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Scientific/Engineering :: Visualization
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: defusedxml>=0.7.1
21
+ Requires-Dist: httpx>=0.25.0
22
+ Requires-Dist: mcp<3,>=2.1
23
+ Requires-Dist: nltk>=3.8
24
+ Requires-Dist: numpy>=1.24
25
+ Requires-Dist: pillow>=10.0
26
+ Requires-Dist: pydantic>=2.0.0
27
+ Requires-Dist: scipy>=1.10
28
+ Provides-Extra: dev
29
+ Requires-Dist: mypy>=1.0.0; extra == 'dev'
30
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
31
+ Requires-Dist: pytest>=7.0.0; extra == 'dev'
32
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # gephi-ai
36
+
37
+ MCP server that bridges any [Model Context Protocol](https://modelcontextprotocol.io) client
38
+ to a running [Gephi Desktop](https://gephi.org) instance, exposing **113 tools** for graph
39
+ construction, statistics, community detection, layout, styling, filtering, and
40
+ publication-ready export.
41
+
42
+ It translates MCP tool calls into HTTP requests against the Gephi AI plugin's local API
43
+ (`http://127.0.0.1:8080`). Each tool has a typed signature, so clients receive a precise
44
+ per-field JSON schema rather than an opaque blob.
45
+
46
+ This is the **MCP server** component of [gephi-ai](https://github.com/MattArtzAnthro/gephi-ai);
47
+ see the top-level repository for the Gephi plugin, the Claude Code plugin, and full docs.
48
+
49
+ ## Install
50
+
51
+ No install needed with [uv](https://docs.astral.sh/uv/) — point your MCP client at:
52
+
53
+ ```bash
54
+ uvx gephi-ai
55
+ ```
56
+
57
+ `uvx` fetches [`gephi-ai` from PyPI](https://pypi.org/project/gephi-ai/) on first run
58
+ and caches it. For a persistent `gephi-ai` command on your `PATH` instead, use
59
+ `pipx install gephi-ai` (or `pipx install .` from this directory). Avoid plain
60
+ `pip install -e .` inside a virtual environment: the command is then only visible on
61
+ that venv's `PATH`, and MCP clients launched outside your shell won't find it.
62
+
63
+ ## Use
64
+
65
+ The Gephi AI plugin must be installed and Gephi Desktop running first. Then point any MCP
66
+ client at the `gephi-ai` command, e.g. for Claude Desktop:
67
+
68
+ ```json
69
+ { "mcpServers": { "gephi-mcp": { "command": "uvx", "args": ["gephi-ai"] } } }
70
+ ```
71
+
72
+ ## Configuration
73
+
74
+ | Env var | Default | Purpose |
75
+ |---|---|---|
76
+ | `GEPHI_API_URL` | `http://127.0.0.1:8080` | Gephi plugin HTTP API base URL |
77
+ | `GEPHI_REQUEST_TIMEOUT` | `60.0` | Per-request timeout (seconds) |
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ pip install -e . pytest pytest-asyncio ruff
83
+ ruff check .
84
+ pytest -q
85
+ ```
86
+
87
+ ## License
88
+
89
+ Apache-2.0 — see the [repository LICENSE](https://github.com/MattArtzAnthro/gephi-ai/blob/main/LICENSE).
@@ -0,0 +1,55 @@
1
+ # gephi-ai
2
+
3
+ MCP server that bridges any [Model Context Protocol](https://modelcontextprotocol.io) client
4
+ to a running [Gephi Desktop](https://gephi.org) instance, exposing **113 tools** for graph
5
+ construction, statistics, community detection, layout, styling, filtering, and
6
+ publication-ready export.
7
+
8
+ It translates MCP tool calls into HTTP requests against the Gephi AI plugin's local API
9
+ (`http://127.0.0.1:8080`). Each tool has a typed signature, so clients receive a precise
10
+ per-field JSON schema rather than an opaque blob.
11
+
12
+ This is the **MCP server** component of [gephi-ai](https://github.com/MattArtzAnthro/gephi-ai);
13
+ see the top-level repository for the Gephi plugin, the Claude Code plugin, and full docs.
14
+
15
+ ## Install
16
+
17
+ No install needed with [uv](https://docs.astral.sh/uv/) — point your MCP client at:
18
+
19
+ ```bash
20
+ uvx gephi-ai
21
+ ```
22
+
23
+ `uvx` fetches [`gephi-ai` from PyPI](https://pypi.org/project/gephi-ai/) on first run
24
+ and caches it. For a persistent `gephi-ai` command on your `PATH` instead, use
25
+ `pipx install gephi-ai` (or `pipx install .` from this directory). Avoid plain
26
+ `pip install -e .` inside a virtual environment: the command is then only visible on
27
+ that venv's `PATH`, and MCP clients launched outside your shell won't find it.
28
+
29
+ ## Use
30
+
31
+ The Gephi AI plugin must be installed and Gephi Desktop running first. Then point any MCP
32
+ client at the `gephi-ai` command, e.g. for Claude Desktop:
33
+
34
+ ```json
35
+ { "mcpServers": { "gephi-mcp": { "command": "uvx", "args": ["gephi-ai"] } } }
36
+ ```
37
+
38
+ ## Configuration
39
+
40
+ | Env var | Default | Purpose |
41
+ |---|---|---|
42
+ | `GEPHI_API_URL` | `http://127.0.0.1:8080` | Gephi plugin HTTP API base URL |
43
+ | `GEPHI_REQUEST_TIMEOUT` | `60.0` | Per-request timeout (seconds) |
44
+
45
+ ## Development
46
+
47
+ ```bash
48
+ pip install -e . pytest pytest-asyncio ruff
49
+ ruff check .
50
+ pytest -q
51
+ ```
52
+
53
+ ## License
54
+
55
+ Apache-2.0 — see the [repository LICENSE](https://github.com/MattArtzAnthro/gephi-ai/blob/main/LICENSE).
@@ -0,0 +1,116 @@
1
+ """Two-mode networks: people by events, authors by concepts, informants by sites.
2
+
3
+ Gephi draws a two-mode network as though every node were the same kind of thing, which
4
+ misrepresents the data at a glance and has no fix in the application: gephi/gephi#3131 asked for a
5
+ bipartite layout and notes the only plugin offering one was removed from the current release, and
6
+ gephi-plugins#130 asked for multimode support in 2016. Projection, the operation that collapses a
7
+ two-mode network into a one-mode one, does not exist in Gephi at all.
8
+
9
+ Both are computed here in Python. The layout is pushed as coordinates and the projection as a new
10
+ graph, so neither needs a Gephi layout plugin or any change to the Java side.
11
+
12
+ Gephi has no concept of a node's mode, so the mode is whatever column the researcher names. That
13
+ is a convention rather than a property of the data, which is why a column carrying more than two
14
+ values is refused rather than guessed at.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from itertools import combinations
20
+ from typing import Any
21
+
22
+ Graph = dict[str, Any]
23
+
24
+
25
+ def _value(attributes: dict[str, Any], column: str) -> Any:
26
+ wanted = column.replace("_", "").replace(" ", "").lower()
27
+ for key, value in (attributes or {}).items():
28
+ if key.replace("_", "").replace(" ", "").lower() == wanted:
29
+ return value
30
+ return None
31
+
32
+
33
+ def split_modes(graph: Graph, column: str) -> tuple[list[str], list[str]]:
34
+ """The two groups of nodes named by `column`, in the order the values first appear."""
35
+ seen: dict[Any, list[str]] = {}
36
+ for node in graph.get("nodes", []):
37
+ value = _value(node.get("attributes"), column)
38
+ if value is None:
39
+ continue
40
+ seen.setdefault(value, []).append(node["key"])
41
+ if not seen:
42
+ raise ValueError(
43
+ f"No node carries a value in {column!r}, so the two modes cannot be told apart.")
44
+ if len(seen) != 2:
45
+ raise ValueError(
46
+ f"{column!r} holds {len(seen)} distinct values ({sorted(map(str, seen))}). A bipartite "
47
+ "graph has exactly two modes; name a column that separates them.")
48
+ left, right = seen.values()
49
+ return left, right
50
+
51
+
52
+ def project_bipartite(graph: Graph, column: str, keep: str) -> dict[str, Any]:
53
+ """Collapse a two-mode network onto one mode, joining nodes that share a partner.
54
+
55
+ Two people who attended the same event become connected, weighted by how many events they
56
+ shared. Nodes sharing nothing are kept with no edges: dropping them would silently delete
57
+ people from the network, which is a different graph rather than a tidier one.
58
+ """
59
+ left, right = split_modes(graph, column)
60
+ modes = {}
61
+ for node in graph.get("nodes", []):
62
+ modes[node["key"]] = _value(node.get("attributes"), column)
63
+
64
+ kept = [k for k in (left + right) if str(modes.get(k)) == str(keep)]
65
+ if not kept:
66
+ raise ValueError(
67
+ f"No node has {column}={keep!r}. Present values: "
68
+ f"{sorted({str(v) for v in modes.values() if v is not None})}.")
69
+
70
+ partners: dict[str, set[str]] = {k: set() for k in kept}
71
+ within = 0
72
+ for edge in graph.get("edges", []):
73
+ s, t = edge["source"], edge["target"]
74
+ if modes.get(s) == modes.get(t):
75
+ within += 1
76
+ continue
77
+ for node, other in ((s, t), (t, s)):
78
+ if node in partners:
79
+ partners[node].add(other)
80
+
81
+ edges = []
82
+ for a, b in combinations(sorted(kept), 2):
83
+ shared = len(partners[a] & partners[b])
84
+ if shared:
85
+ edges.append({"source": a, "target": b, "weight": shared})
86
+
87
+ result: dict[str, Any] = {
88
+ "nodes": sorted(kept),
89
+ "edges": edges,
90
+ "kept_mode": keep,
91
+ "within_mode_edges": within,
92
+ }
93
+ if within:
94
+ result["warning"] = (
95
+ f"{within} edge(s) join two nodes of the same mode, so this graph is not bipartite. "
96
+ "Those edges were ignored in the projection; check the mode column before relying on "
97
+ "the result.")
98
+ return result
99
+
100
+
101
+ def bipartite_positions(graph: Graph, column: str, *,
102
+ separation: float = 600.0, spacing: float = 60.0) -> list[dict[str, Any]]:
103
+ """Coordinates placing each mode in its own column, which is what makes the shape legible.
104
+
105
+ Returned in the shape gephi_batch_set_positions accepts, so the layout is pushed rather than
106
+ implemented as a Gephi layout plugin.
107
+ """
108
+ left, right = split_modes(graph, column)
109
+ positions = []
110
+ for index, members in enumerate((left, right)):
111
+ x = -separation / 2 if index == 0 else separation / 2
112
+ span = (len(members) - 1) * spacing
113
+ for row, key in enumerate(members):
114
+ positions.append({"id": key, "x": float(x),
115
+ "y": float(row * spacing - span / 2)})
116
+ return positions
@@ -0,0 +1,189 @@
1
+ {
2
+ "schema": 1,
3
+ "note": "Known defects in Gephi's own statistics, surfaced alongside the numbers they affect. An entry is only asserted as live once a probe has reproduced it against a running Gephi; until then its status is 'unverified' and its text says so. Run the probe suite (tests/probes/) to update the verification blocks.",
4
+ "caveats": [
5
+ {
6
+ "id": "gephi-2034",
7
+ "issues": [
8
+ "https://github.com/gephi/gephi/issues/2034"
9
+ ],
10
+ "metrics": [
11
+ "modularity"
12
+ ],
13
+ "applies_when": {
14
+ "param_not_default": {
15
+ "name": "resolution",
16
+ "default": 1.0
17
+ }
18
+ },
19
+ "severity": "reporting",
20
+ "says": "Gephi applies the modularity resolution parameter as the RECIPROCAL of the convention used in Blondel et al., the paper its own dialogue cites. A resolution reported from Gephi does not mean what a reader of that literature will take it to mean, and higher values here yield FEWER communities rather than more. The issue has been open since 2018 and concludes it cannot be changed without invalidating every value users have already recorded, so treat this as permanent. Not verified against your Gephi: run the probe suite to confirm it still holds.",
21
+ "reported_open_since": "2018-10-03",
22
+ "verification": {
23
+ "status": "unverified",
24
+ "probe": "probe_gephi_2034"
25
+ }
26
+ },
27
+ {
28
+ "id": "gephi-1872",
29
+ "issues": [
30
+ "https://github.com/gephi/gephi/issues/1872"
31
+ ],
32
+ "metrics": [
33
+ "closeness",
34
+ "harmonic_closeness",
35
+ "avg_path_length"
36
+ ],
37
+ "applies_when": {
38
+ "always": true
39
+ },
40
+ "severity": "reporting",
41
+ "says": "Closeness centrality is normalised whether or not the normalisation checkbox was ticked, while betweenness respects the setting. Two measures from the same dialogue therefore behave differently, and a closeness value cannot be assumed to be on the scale you asked for. This one cannot be checked automatically from here, so it is reported as filed rather than confirmed.",
42
+ "reported_open_since": "2017-12-09",
43
+ "verification": {
44
+ "status": "not_probeable",
45
+ "why": "Cannot be probed through this API: confirming it needs the normalisation toggle off, and the statistics endpoint exposes no such parameter."
46
+ }
47
+ },
48
+ {
49
+ "id": "gephi-2191",
50
+ "issues": [
51
+ "https://github.com/gephi/gephi/issues/2191"
52
+ ],
53
+ "metrics": [
54
+ "pagerank"
55
+ ],
56
+ "applies_when": {
57
+ "undirected": true
58
+ },
59
+ "severity": "wrong",
60
+ "says": "PageRank is reported to be computed incorrectly for undirected graphs, and this graph is undirected. Treat the values as unreliable for ranking until checked against an independent implementation. Not verified against your Gephi: run the probe suite to confirm it still holds.",
61
+ "reported_open_since": "2020-05-20",
62
+ "verification": {
63
+ "status": "unverified",
64
+ "why": "Deciding this needs an independent implementation of the measure to compare against, and a mismatch of conventions between the two would look identical to a fix. No probe is written rather than one that could give a false all-clear."
65
+ }
66
+ },
67
+ {
68
+ "id": "gephi-557",
69
+ "issues": [
70
+ "https://github.com/gephi/gephi/issues/557",
71
+ "https://github.com/gephi/gephi/issues/1817"
72
+ ],
73
+ "metrics": [
74
+ "betweenness",
75
+ "closeness",
76
+ "harmonic_closeness",
77
+ "eigenvector"
78
+ ],
79
+ "applies_when": {
80
+ "weights_vary": true
81
+ },
82
+ "severity": "wrong",
83
+ "says": "This graph carries edge weights that vary, but Gephi's centrality measures are reported not to use them, so the result describes an unweighted version of your network. If the weights carry meaning, this number is not measuring what you think it measures. Open since 2012. Not verified against your Gephi: run the probe suite to confirm it still holds.",
84
+ "reported_open_since": "2012-03-14",
85
+ "verification": {
86
+ "status": "unverified",
87
+ "probe": "probe_gephi_557"
88
+ }
89
+ },
90
+ {
91
+ "id": "gephi-modularity-unstable",
92
+ "issues": [
93
+ "https://github.com/gephi/gephi/issues/2002",
94
+ "https://github.com/gephi/gephi/issues/2735",
95
+ "https://github.com/gephi/gephi/issues/2888"
96
+ ],
97
+ "metrics": [
98
+ "modularity"
99
+ ],
100
+ "applies_when": {
101
+ "always": true
102
+ },
103
+ "severity": "unstable",
104
+ "says": "This partition is one draw, not the answer. Gephi's community detection is reported to give different results between runs with randomisation switched off, to change with the order the node and edge tables were imported, and to change after a layout has been run, which should not touch a partition at all. Do not describe these communities as findings until you know they are stable: call gephi_community_stability to run detection repeatedly and report which groups hold up. Not verified against your Gephi: run the probe suite to confirm it still holds.",
105
+ "reported_open_since": "2018-08-01",
106
+ "verification": {
107
+ "status": "unverified",
108
+ "probe": "probe_modularity_unstable"
109
+ }
110
+ },
111
+ {
112
+ "id": "gephi-2951",
113
+ "issues": [
114
+ "https://github.com/gephi/gephi/issues/2951"
115
+ ],
116
+ "metrics": [
117
+ "closeness",
118
+ "harmonic_closeness"
119
+ ],
120
+ "applies_when": {
121
+ "always": true
122
+ },
123
+ "severity": "wrong",
124
+ "says": "Closeness centrality is reported to produce wrong results. The report is untriaged, so the conditions under which it goes wrong are not established. Not verified against your Gephi: run the probe suite to confirm it still holds.",
125
+ "reported_open_since": "2024-10-11",
126
+ "verification": {
127
+ "status": "unverified",
128
+ "why": "Deciding this needs an independent implementation of the measure to compare against, and a mismatch of conventions between the two would look identical to a fix. No probe is written rather than one that could give a false all-clear."
129
+ }
130
+ },
131
+ {
132
+ "id": "gephi-2145",
133
+ "issues": [
134
+ "https://github.com/gephi/gephi/issues/2145"
135
+ ],
136
+ "metrics": [
137
+ "eigenvector"
138
+ ],
139
+ "applies_when": {
140
+ "always": true
141
+ },
142
+ "severity": "wrong",
143
+ "says": "Eigenvector centrality is reported not to be calculated properly. The report is untriaged. Check against an independent implementation before ranking on it. Not verified against your Gephi: run the probe suite to confirm it still holds.",
144
+ "reported_open_since": "2019-10-10",
145
+ "verification": {
146
+ "status": "unverified",
147
+ "why": "Deciding this needs an independent implementation of the measure to compare against, and a mismatch of conventions between the two would look identical to a fix. No probe is written rather than one that could give a false all-clear."
148
+ }
149
+ },
150
+ {
151
+ "id": "gephi-858",
152
+ "issues": [
153
+ "https://github.com/gephi/gephi/issues/858"
154
+ ],
155
+ "metrics": [
156
+ "clustering_coefficient"
157
+ ],
158
+ "applies_when": {
159
+ "always": true
160
+ },
161
+ "severity": "wrong",
162
+ "says": "The average clustering coefficient is reported to be wrong. Open and untriaged since 2013. Not verified against your Gephi: run the probe suite to confirm it still holds.",
163
+ "reported_open_since": "2013-11-22",
164
+ "verification": {
165
+ "status": "unverified",
166
+ "probe": "probe_gephi_858"
167
+ }
168
+ },
169
+ {
170
+ "id": "gephi-1784",
171
+ "issues": [
172
+ "https://github.com/gephi/gephi/issues/1784"
173
+ ],
174
+ "metrics": [
175
+ "betweenness"
176
+ ],
177
+ "applies_when": {
178
+ "always": true
179
+ },
180
+ "severity": "reporting",
181
+ "says": "Betweenness centrality from Gephi is reported to differ from NodeXL on the same graph, with no explanation recorded on the issue. If you are comparing figures with work done in another tool, the difference may be the tool rather than the data. This one cannot be checked automatically from here, so it is reported as filed rather than confirmed.",
182
+ "reported_open_since": "2017-09-16",
183
+ "verification": {
184
+ "status": "not_probeable",
185
+ "why": "Cannot be probed through this API: confirming it needs NodeXL, or another independent implementation, to compare against."
186
+ }
187
+ }
188
+ ]
189
+ }
@@ -0,0 +1,120 @@
1
+ """Consensus analysis over repeated community detection.
2
+
3
+ Gephi runs community detection once and reports a partition as though it were the answer. It is
4
+ one draw. The same graph run again can give a different partition (gephi#2002), and the result
5
+ changes with the order the tables were imported (gephi#2888) and even after a layout has run
6
+ (gephi#2735), which should not touch a partition at all.
7
+
8
+ gephi#2968 asked Gephi for exactly this analysis and was closed as not planned, so nothing in this
9
+ ecosystem can currently answer the first question anyone should ask of a community result: are
10
+ these groups real, or are they an artefact of one run?
11
+
12
+ The measure used here is co-assignment. Across N runs, every pair of nodes has a rate at which the
13
+ two landed in the same community. A rate of 1.0 or 0.0 is decisive; 0.5 is a coin flip. A node's
14
+ stability is the average decisiveness of its relations with every other node, so 1.0 means every
15
+ relation came out the same way every time, and 0.5 means the node's membership is undetermined.
16
+
17
+ The consensus partition keeps the pairs that agreed more often than not, which leaves a node that
18
+ agrees with nobody standing on its own rather than being forced into a group it only half joined.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from itertools import combinations
24
+ from typing import Any
25
+
26
+ Partition = dict[str, Any]
27
+
28
+
29
+ def _canonical(partition: Partition) -> frozenset[frozenset[str]]:
30
+ """A partition as its set of groups, so arbitrary community labels stop mattering.
31
+
32
+ Gephi numbers communities differently between runs. {a,b}|{c,d} is the same partition whether
33
+ the groups are called 1 and 2 or 7 and 3, and counting relabellings as different outcomes
34
+ would report instability that is not there.
35
+ """
36
+ groups: dict[Any, set[str]] = {}
37
+ for node, community in partition.items():
38
+ groups.setdefault(community, set()).add(node)
39
+ return frozenset(frozenset(g) for g in groups.values())
40
+
41
+
42
+ def _co_assignment(runs: list[Partition]) -> tuple[dict[tuple[str, str], float], list[str]]:
43
+ """For every pair of nodes, the fraction of the runs containing both that grouped them."""
44
+ nodes = sorted({n for run in runs for n in run})
45
+ rates: dict[tuple[str, str], float] = {}
46
+ for a, b in combinations(nodes, 2):
47
+ shared = [r for r in runs if a in r and b in r]
48
+ if not shared:
49
+ continue
50
+ together = sum(1 for r in shared if r[a] == r[b])
51
+ rates[(a, b)] = together / len(shared)
52
+ return rates, nodes
53
+
54
+
55
+ def _components(nodes: list[str], edges: set[tuple[str, str]]) -> list[list[str]]:
56
+ parent = {n: n for n in nodes}
57
+
58
+ def find(x: str) -> str:
59
+ while parent[x] != x:
60
+ parent[x] = parent[parent[x]]
61
+ x = parent[x]
62
+ return x
63
+
64
+ for a, b in edges:
65
+ ra, rb = find(a), find(b)
66
+ if ra != rb:
67
+ parent[ra] = rb
68
+
69
+ groups: dict[str, list[str]] = {}
70
+ for n in nodes:
71
+ groups.setdefault(find(n), []).append(n)
72
+ return [sorted(g) for g in groups.values()]
73
+
74
+
75
+ def consensus(runs: list[Partition]) -> dict[str, Any]:
76
+ """Summarise how reproducible a set of community-detection runs was.
77
+
78
+ Returns the number of genuinely distinct partitions seen, a stability score per node, the
79
+ least stable nodes, and a consensus partition built from the pairs that agreed more often
80
+ than not.
81
+
82
+ A single run returns `mean_stability: None` and a warning rather than a score. One draw
83
+ carries no information about reproducibility, and reporting 1.0 there would assert exactly
84
+ the thing the caller asked us to check.
85
+ """
86
+ runs = [r for r in (runs or []) if r]
87
+ if not runs:
88
+ return {"runs": 0, "distinct_partitions": 0, "node_stability": {},
89
+ "mean_stability": None, "unstable_nodes": [], "consensus_groups": [],
90
+ "warning": "No runs were recorded, so nothing can be said about stability."}
91
+
92
+ distinct = len({_canonical(r) for r in runs})
93
+
94
+ if len(runs) == 1:
95
+ return {"runs": 1, "distinct_partitions": distinct, "node_stability": {},
96
+ "mean_stability": None, "unstable_nodes": [],
97
+ "consensus_groups": [sorted(g) for g in _canonical(runs[0])],
98
+ "warning": ("Only one run was recorded. One draw says nothing about whether the "
99
+ "partition is reproducible; run it several times to find out.")}
100
+
101
+ rates, nodes = _co_assignment(runs)
102
+
103
+ stability: dict[str, float] = {}
104
+ for node in nodes:
105
+ decisiveness = [max(p, 1.0 - p)
106
+ for (a, b), p in rates.items() if node in (a, b)]
107
+ stability[node] = round(sum(decisiveness) / len(decisiveness), 4) if decisiveness else 1.0
108
+
109
+ agreed = {pair for pair, p in rates.items() if p > 0.5}
110
+ groups = _components(nodes, agreed)
111
+
112
+ ranked = sorted(stability.items(), key=lambda kv: (kv[1], kv[0]))
113
+ return {
114
+ "runs": len(runs),
115
+ "distinct_partitions": distinct,
116
+ "node_stability": stability,
117
+ "mean_stability": round(sum(stability.values()) / len(stability), 4),
118
+ "unstable_nodes": [{"node": n, "stability": s} for n, s in ranked[:10]],
119
+ "consensus_groups": sorted(groups, key=lambda g: (-len(g), g[0])),
120
+ }