feat(packaging): gate the leiden extra by interpreter so Leiden works on 3.13+ - #3310
feat(packaging): gate the leiden extra by interpreter so Leiden works on 3.13+#3310andrebrait wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
Graphify reviewed this change.
Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.
Graphify review — findings
Adds a leiden-native extra pinning graspologic-native>=1.2.1 with no interpreter marker, giving Leiden community detection on Python ≥ 3.13 where the existing leiden extra can't install and _partition() would otherwise fall back to Louvain. Folds the new dependency into the all extra and documents it in the README install table. Adds test_leiden_native_extra_reaches_every_supported_python asserting the extra stays marker-free, contains only graspologic-native, leaves leiden unchanged, and that both remain subsets of all.
Worth a look
allextra now changes Leiden backend for existing users —pyproject.toml:103· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
- Test module imports Python 3.11-only tomllib —
tests/test_wheel_packaging.py:12· Escalate · medium- agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 39 functions depend on the 39 functions this change touches.
Health — grade A; no new coupling hotspots.
Verification — 39 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 39 function(s) in the blast radius were not formally verified this run
7132b77 to
89f4c6e
Compare
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
Extends the leiden extra to install graspologic-native directly on Python 3.13+ where graspologic's metadata no longer pulls in the binding, so Leiden community detection keeps working instead of silently falling back to NetworkX Louvain; the same second arm is added to the all extra. Adds tests asserting the two leiden arms stay complementary and exhaustive across supported interpreters and that all carries both.
No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 39 functions depend on the 39 functions this change touches.
Health — grade A; no new coupling hotspots.
Verification — 39 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 39 function(s) in the blast radius were not formally verified this run
_partition() calls graspologic_native.leiden() first and falls back to the graspologic wrapper, then to NetworkX Louvain (Graphify-Labs#3104). Below Python 3.13 the graspologic distribution supplies that binding as a dependency of its own, so the extra works. From 3.13 graspologic's metadata excludes the interpreter, so `uv tool install "graphifyy[leiden]"` installs nothing at all, the native import fails, and clustering silently drops to Louvain -- users lose Leiden's connectivity guarantee with no diagnostic, and the README documents the limitation rather than a way around it. graspologic-native publishes cp39-abi3 wheels for Linux, macOS and Windows, so naming the binding directly on 3.13+ restores Leiden with no compiler and without graspologic's umap/pynndescent/numba install chain. The two arms are complementary and exhaustive, so `[leiden]` installs a working backend on every supported interpreter and existing installs below 3.13 resolve to exactly what they resolve to today. The ceiling on the 3.13+ arm is the one graspologic itself sets on the same package.
89f4c6e to
ce62505
Compare
There was a problem hiding this comment.
Graphify reviewed this change.
Looks safe to merge — no coupling regressions and no blocking issues, checked against the code graph (not a self-assessment).
Graphify review — findings
Adds a native Leiden backend for Python 3.13+ by having the leiden extra (and all) depend on graspologic-native>=1.2.1,<2.0.0 on that interpreter while keeping graspologic below 3.13, so [leiden] installs a working backend everywhere instead of silently degrading to NetworkX Louvain where graspologic's metadata drops out. Guards this with tests asserting the two arms are complementary, exhaustive, and both carried by all. Updates the README extras table and refreshes uv.lock accordingly.
No blocking issues surfaced. 6 lower-confidence candidates did not survive cross-model review.
Analysis details — impact, health, verification
Impact & health
Graphify review
Impact — 39 functions depend on the 39 functions this change touches.
Health — grade A; no new coupling hotspots.
Verification — 39 functions in the blast radius were not formally verified this run (proofs are advisory here).
Gate & verification
graphify gate
PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.
Advisory (not blocking):
- verification_scope: 39 function(s) in the blast radius were not formally verified this run
Problem
_partition()callsgraspologic_native.leiden()first, then thegraspologicwrapper, then falls back to NetworkX Louvain (#3104). Theleidenextra installs thegraspologicdistribution, whose metadata isrequires_python = <3.13,>=3.9.Below 3.13 that works —
graspologicrequiresgraspologic-native>=1.2.1,<2.0.0itself, so the native binding_native_leiden()calls is already present. From 3.13 the marker excludes the interpreter, souv tool install "graphifyy[leiden]"installs nothing, the native import fails, and clustering silently drops to Louvain. The extra is a no-op on a current interpreter, users lose Leiden's connectivity guarantee with no diagnostic, and the README's "Python < 3.13 only" note documents the limitation rather than a way around it.Change
One extra, gated by interpreter:
allmirrors the same pair. README row updated.uv.lockregenerated. Guard added totests/test_backend_extras.py.The arms are complementary and exhaustive: every supported interpreter gets a working backend, and an install below 3.13 resolves to exactly what it resolves to today.
graspologic-nativepublishescp39-abi3wheels (Linuxmanylinux/musllinuxx86_64+aarch64, macOS universal2, Windows amd64/arm64), so 3.13+ needs no compiler and skips graspologic's umap/pynndescent/numba install chain.Why the 3.13+ arm has a ceiling
Not because
graspologicsets one — because of how_native_leiden()calls the binding. It passes eight exact keyword arguments (edges,starting_communities,resolution,randomness,iterations,use_modularity,seed,trials) and unpacks a 2-tuple return, and the whole call sits insideexcept Exception: return None(cluster.py:73-91). A breaking major would therefore not raise — it would returnNone, fall through to agraspologicthat is absent on 3.13+, and land on Louvain. That is exactly the silent degradation this change removes, so an uncapped arm could quietly re-introduce the bug on someone's nextuv tool upgrade.It also keeps the arms symmetric: below 3.13 the effective constraint is already
<2.0.0via graspologic, so without a ceiling agraspologic-native2.0 release would leave <3.13 on 1.x while 3.13+ jumped — the arms diverging is the one thing the gate is meant to prevent. House style is the same shape for directly-consumed APIs (mcp>=1,<3,starlette>=1.3.1,<2, every tree-sitter grammar), while broad stable dependencies stay uncapped (networkx>=3.4,numpy>=1.21). The cost is the usual one — 3.13+ users wait for a graphify release after a 2.0 — and I'd take that over silent Louvain, but happy to drop the ceiling if you weigh it the other way.Addressing the review on 7132b77
Both advisory findings are handled, and the first one prompted the redesign — thank you, it is a better change than what I first pushed.
"
allextra now changes Leiden backend for existing users" — correct to flag, and the gate now makes it structurally impossible: below 3.13allresolves tograspologicalone, unchanged. Worth recording that the original concern was also already moot, becausegraspologicdepends ongraspologic-native:so
[leiden]/[all]on <3.13 already installed the native binding and_partition()already took_native_leiden(). The unmarked line did carry a real latent bug, though — no ceiling, so a futuregraspologic-native2.x would have conflicted with graspologic's own<2.0.0cap on <3.13. The gate plus the matching ceiling removes that."Test module imports Python 3.11-only
tomllib" — a genuine break on the>=3.10floor, thank you. The guard moved totests/test_backend_extras.py, which is both the house home for extras assertions and already carries thetomllib/tomlifallback this repo uses (graphify/cargo_introspect.py:12-21), so no new import appears anywhere.tests/test_wheel_packaging.pyis untouched.Verification
The guard fails against
v8metadata (expected exactly one graspologic-native arm), and every assertion is mutation-checked — each of these turns it red:v8baseline, no 3.13+ armalldrops the 3.13+ armBehaviour on Python 3.13.5, real 23.7k-node / 50k-edge corpus: the native path produces 1115 communities against Louvain's 1131, with the largest communities shrinking from 619/466/422 nodes to 364/359/339 — Leiden's refinement pass doing what it should — and byte-identical output across
PYTHONHASHSEED=0and=9.Note
ruff format --checkalready reportstests/test_backend_extras.pyas needing reformatting on unmodifiedv8(a docstring blank line and a quote style, both outside my hunks); I left that alone rather than mixing a whole-file reformat into this diff.ruff checkis clean.