Track: Track2; Team name: r2; Model: PolynomialFilterTNN + FilterBankTNN (SNN/SCNN) - #414
Open
aniervs wants to merge 22 commits into
Open
Track: Track2; Team name: r2; Model: PolynomialFilterTNN + FilterBankTNN (SNN/SCNN)#414aniervs wants to merge 22 commits into
aniervs wants to merge 22 commits into
Conversation
Add a single-polynomial-filter graph backbone that implements
y = post(sum_{k=0..K} theta_k * T_k(L_norm) * pre(x))
where {T_k} is a polynomial sequence produced by a swappable basis.
The backbone owns the propagation loop, the coefficients theta_k, the
accumulation, the pre/post MLPs, the Laplacian normalization
convention, and the (x, edge_index, batch, edge_weight) interface
expected by GNNWrapper. The basis owns the recurrence and any
parameters the recurrence needs.
The basis interface (poly_filter/basis.py) is:
class Basis(nn.Module):
def init(self, x, L_apply) -> u_0
def effective_thetas(self, backbone_theta) -> theta_eff
def forward(self, u_prev, u_prev_prev, L_apply, signal, k) -> u_k
with a single uniform forward signature shared by signal-independent
and signal-dependent bases. Bases are nn.Module subclasses so they can
own learnable parameters. The backbone treats every basis as opaque
and never branches on the concrete class. Adding a new basis is a
single new file plus a Hydra _target_ swap.
Reference: Liao et al. (2024) "A Comprehensive Benchmark on Spectral
GNNs", SIGMOD '26, arXiv:2406.09675 -- survey unifying every variable-
basis spectral GNN under this recurrence-in-L_norm template.
Concrete bases land in a follow-up commit.
Register seven bases under topobench.nn.backbones.graph.poly_filter.bases,
each a Basis subclass with the recurrence transcribed from Liao
Appendix B and the corresponding primary paper:
- Monomial: u_k = L_norm * u_{k-1} (GPR-GNN family;
Chien, Peng, Li & Milenkovic 2021, arXiv:2006.07988).
- Chebyshev (first kind): three-term recurrence; boundary at k=1
handled inside the basis via u_prev_prev=None. Covers ChebNet
(Defferrard et al. 2016, arXiv:1606.09375) and ChebBase
(He, Wei & Wen 2022, arXiv:2202.03580).
- ChebNetII: Chebyshev recurrence + interpolation reparameterization
of theta via the discrete Chebyshev transform at Chebyshev nodes
(He, Wei & Wen 2022). The only basis here that uses the
effective_thetas protocol hook; the backbone's theta is inert by
design.
- Jacobi(alpha, beta): three-term recurrence with k-dependent
coefficients (Wang & Zhang 2022, arXiv:2205.11172). First basis
where the k argument is genuinely consumed.
- Legendre: shipped as Jacobi(alpha=0, beta=0). Liao's standalone
Legendre recurrence uses z=L_norm directly, which evaluates P_k
outside its [-1, 1] orthogonality interval (L_norm has eigenvalues
in [0, 2]) and grows as Theta(3^k/sqrt(k)) at the spectrum
boundary. The Jacobi reparameterization shifts to z=I-L_norm and
is uniformly bounded. Documented as the deviation from Liao's
literal formula.
- FavardGNN: three-term recurrence with learnable coefficients
a_k = sqrt(alpha_k) (parameterized via softplus to guarantee
positivity) and beta_k (Guo & Wei 2023, arXiv:2302.12432).
First basis owning learnable parameters of its own.
- OptBasisGNN: Lanczos-style orthonormal recurrence where alpha, gamma
are derived from inner products on the running signal u_prev
(Guo & Wei 2023, arXiv:2302.12432, Theorem 4.1). The signal-
dependent basis in the registry; demonstrates the uniform-signature
protocol survives the load-bearing case.
Add the Hydra config configs/model/graph/polynomial_filter_gnn.yaml
defaulting to Chebyshev; bases are swappable via the CLI override
model.backbone.basis._target_=...<Name>.
Bernstein is deliberately omitted: Liao Appendix B presents it in
closed form per k (O(K^2 m F) vs O(K m F) for every other variable
basis), so it does not fit the three-term recurrence protocol
without stretching the abstraction. Deferred.
Add 70 unit tests under test/nn/backbones/graph/test_polynomial_filter_gnn.py
covering:
- TestPolynomialFilterGNN: backbone propagation loop is basis-agnostic;
basis receives the uniform (u_prev, u_prev_prev, L_apply, signal, k)
signature on every step; default Basis.init returns the signal
untouched; backbone runs under sym/rw/none Laplacian normalizations;
the _build_laplacian_apply closure matches a hand-computed symmetric
Laplacian on a path graph; K=0 and invalid-K guard.
- TestMonomialBasis: single-step recurrence applies L_apply once;
stateless w.r.t. signal and k.
- TestChebyshevBasis: k=1 boundary returns L u_0 (not 2L u_0); for
L = alpha*I the basis collapses to T_k(alpha)*x for classical
first-kind T_k.
- TestJacobiBasis: hyperparameter validation; k=1 closed form at L=0;
for L = gamma*I the basis collapses to P_k^{(alpha,beta)}(1-gamma)*x
computed from Liao's own recurrence; symmetric (alpha=beta) case
kills the middle delta'_k term cleanly.
- TestLegendreBasis: literal equivalence to Jacobi(0, 0) on every k;
uniform boundedness |u_k| <= 1 across the full L eigenvalue range
[0, 2] -- the property that motivated shipping Jacobi(0, 0) rather
than Liao's standalone z=L Legendre formula.
- TestChebNetIIBasis: M[k, kappa] = (2/(K+1)) * T_k(x_kappa) matches
hand computation for K=2; effective_thetas returns M @ theta_interp
ignoring backbone_theta; K mismatch raises ValueError; gradient
reaches basis.theta_interp and backbone.theta.grad is None by
design.
- TestFavardGNNBasis: 2(K+1) learnable parameters; softplus keeps
a_k strictly positive at extreme raw values; k=1 and k=2 closed
forms; gradients flow to both a_raw and beta.
- TestOptBasisGNN: init normalizes per-channel and resets state;
OptBasis(c*x) = OptBasis(x) scale invariance (the literal signature
of signal-dependence), contrasted with Chebyshev(c*x) = c*Chebyshev(x);
Lanczos orthonormality <u_k, u_j> approx delta_{kj}; repeated
forward passes do not leak state across each other.
- TestPolynomialFilterGNNHydraConfig: full run.yaml composes with
graph/polynomial_filter_gnn + graph/MUTAG; basis swaps via
model.backbone.basis._target_ overrides for Monomial, Jacobi (with
hyperparameters), Legendre, ChebNetII (with K interpolation from
the backbone), FavardGNN (with K interpolation), and OptBasisGNN.
Add graph/polynomial_filter_gnn to MODELS in test/pipeline/test_pipeline.py
so the backbone trains 2 epochs on MUTAG as part of the standard
wire-up gate.
Three style consistency fixes across the new poly_filter/ subpackage and its tests: - Switch docstrings that carry .. math:: blocks to raw strings (r"""..."""). The runtime content is unchanged, but source code now shows \tilde L, \sum, \frac directly instead of \\tilde L, \\sum, \\frac. Easier to read and edit. - Pick one form for the normalized Laplacian per context: \tilde L inside .. math:: blocks (where Sphinx renders it), and the Unicode character L_tilde everywhere else in prose. Previously mixed across the same file. - Drop em dashes throughout in favour of ASCII punctuation (colons for explanatory clauses, semicolons or parentheses elsewhere). Matches the rest of the project's plain-ASCII docstring style. No behaviour change. All pre-commit hooks (ruff, ruff-format, numpydoc-validation) pass; 70/70 polynomial-filter tests pass.
Add one standalone model config per registered basis so each can be
selected in the TDL challenge evaluation notebook via a single
MODEL_CONFIG string (the notebook is unmodifiable and takes only a
config path, not basis overrides):
graph/polynomial_filter_gnn_monomial
graph/polynomial_filter_gnn_chebyshev
graph/polynomial_filter_gnn_chebnetii
graph/polynomial_filter_gnn_jacobi
graph/polynomial_filter_gnn_legendre
graph/polynomial_filter_gnn_favard
graph/polynomial_filter_gnn_optbasis
Each file mirrors polynomial_filter_gnn.yaml; only the basis block and
model_name differ. The hyperparameterized bases pin their extra args:
ChebNetII and FavardGNN set K: ${model.backbone.K} so the basis size
tracks the backbone degree, and Jacobi sets explicit alpha=beta=1.0.
Distinct model_name values keep per-basis output dirs and results.json
from colliding.
All seven verified end-to-end through the challenge harness
(topobench.run.run) on both tasks (community_detection node-level,
triangle_counting graph-level), 1 epoch each: 14/14 configs compose,
instantiate the correct basis, and train without error. The default
polynomial_filter_gnn.yaml (Chebyshev) is unchanged and remains the
config exercised by the unit and pipeline tests.
One results.json per registered PolynomialFilterGNN basis, produced by the challenge harness over the full 12-setting x 3-seed grid on both tasks (community detection, triangle counting): 72 runs per basis, 504 total. Includes comparison_summary.csv (in-distribution + OOD aggregate per basis). Tracked under 2026_tdl_challenge/outputs/ as the challenge submission files.
Register the Bernstein (Bezier) basis, completing coverage of every
variable basis in Liao Appendix B. It is the one non-orthogonal,
closed-form member:
g(L_norm; theta) = sum_k (theta_k / 2^K) C(K,k) (2I-L_norm)^(K-k) L_norm^k
It fits the unchanged Basis.forward(u_prev, u_prev_prev, L_apply, signal,
k) signature by ignoring the recurrence arguments (the way signal-
independent bases ignore signal) and building each u_k from the input
signal; the binomial / 2^K normalization is folded in via
effective_thetas (a scaling, not a replacement), so the backbone's theta
stays learnable. Takes K in __init__ like ChebNetII/FavardGNN (binomials
are baked in), wired in the config as K: ${model.backbone.K}.
This is the only O(K^2 m F) basis -- the fingerprint of not having the
orthogonal-polynomial three-term-recurrence structure -- documented in
bernstein.py. The partition-of-unity property (all theta_k = 1 gives the
identity filter) is the load-bearing correctness test.
Primary reference: He et al. (2021) "BernNet: Learning Arbitrary Graph
Spectral Filters via Bernstein Approximation" (NeurIPS, arXiv:2106.10994).
Adds bernstein.py, registry entry, polynomial_filter_gnn_bernstein.yaml,
and TestBernsteinBasis (+ Hydra smoke). 79 poly-filter tests pass.
This submission only contributes the polynomial-filter backbone, so the pipeline smoke test runs just graph/polynomial_filter_gnn (the default Chebyshev basis), which exercises the encoder -> wrapper -> readout -> loss -> trainer wire-up for the new backbone.
Bernstein full-grid results (12 settings x 3 seeds x 2 tasks = 72 runs) from the challenge harness, and the regenerated comparison_summary.csv now covering all eight bases. Bernstein lands in the expressive-basis cluster: 4th of 8 on community detection (in-dist 0.492, OOD 0.438, tied with Favard/Jacobi/Legendre) and 7th on triangle counting (mse 41.6k) -- the same classification-vs- regression inversion the other flexible bases show.
…ference
Cross-checked against Liao et al. (2024) "A Comprehensive Benchmark on
Spectral GNNs" official code (gdmnl/Spectral-GNN-Benchmark). These change
the trained model and require retraining all bases:
- Chebyshev: run the recurrence in (L̃ - I) in [-1, 1] rather than
L̃ in [0, 2] (the standard ChebNet rescale, matching Liao's 'L-I'
scheme). Keeps T_k bounded; the previous argument blew up at the
high-frequency end of the spectrum.
- Laplacian: add GCN self-loop renormalization
(Â = D̂^{-1/2}(A+I)D̂^{-1/2}, L̃ = I - Â) behind a `self_loops` flag
(default True), matching Liao's gen_norm. Affects every basis.
- ChebNetII: DC half-weight on the interpolation matrix and a ReLU on
the learnable node values, matching Liao's chebii_conv.
- Legendre: correct the docstring -- our Jacobi(α=β=0) form evaluated at
 agrees with Liao's legendre_conv; it does not deviate from it.
Also a framework-neutral documentation pass over the poly_filter
docstrings, test docstrings, and config comments (standard Hydra-override
usage in configs).
…fixes Re-ran the full GraphUniverse grid (8 bases x 12 settings x 3 seeds x 2 tasks = 576 runs) on the convention-aligned models (b11a542): Chebyshev domain rescale to [-1,1], GCN self-loop renormalization, and the ChebNetII interpolation fixes. ChebNetII is repaired: community-detection accuracy 0.344 -> 0.491 (it was previously stuck at the heterophily floor across all homophily levels). The CD spread across bases tightens to 0.481-0.498, and the triangle-counting ranking shifts -- Chebyshev/Monomial no longer lead it, ChebNetII/OptBasis now do.
A multi-channel backbone for the filter-bank pattern
y = fuse_q(gamma_q * g_q(L̃) x): Q parallel polynomial filters fused with
learnable channel weights gamma. Each channel reuses the polynomial-filter
basis registry; the backbone owns gamma and the pre/post MLPs and delegates
combination to a swappable Fusion.
Variants (Liao et al. 2024 filter-bank category, cross-checked against the
official gdmnl/Spectral-GNN-Benchmark code):
- ACMGNN -- LP + HP + identity linear filters (Luan et al. 2022)
- FBGNN -- LP + HP linear filters (Luan et al. 2022)
- FAGCN -- LP + HP with a scaling hyperparameter (Bo et al. 2021)
- GNNLFHF -- two PPR channels with an (I +/- beta*L̃) prefactor (Zhu et al. 2021)
- FiGURe -- Q learnable-theta channels, canonical Monomial/Chebyshev/
Bernstein set (Ekbote et al. 2023)
- G2CN -- two Gaussian band-pass channels, sum alpha^k/k! ((1+/-beta)I-L̃)^(2k)
(Li et al. 2022)
The Laplacian uses the same GCN self-loop renormalization as
PolynomialFilterGNN. Scoped out: AdaGNN (per-feature diagonal filtering, not a
gamma-fused parallel bank) and the original models' node-wise attention /
unsupervised pretraining (the channel-weighted-sum spectral forms are shipped).
Unit tests for every variant plus the shared Channel/Fusion/GaussianChannel
helpers, per-variant Hydra instantiation, and the pipeline test now covering
all six variants on MUTAG.
Full GraphUniverse grid (6 variants x 12 settings x 3 seeds x 2 tasks =
432 runs) for the FilterBankGNN backbone, plus the comparison table.
FiGURe leads (CD 0.499, TRI 42.6k), matching the best single polynomial
filter -- its free degree-K channels recover the poly-filter ceiling.
G2CN / GNN-LF-HF sit mid-pack (CD ~0.467); the three linear banks
(ACM-GNN / FAGCN / FBGNN) span the same {aI + bL̃} family and land
identically at CD 0.406. Channel expressiveness, not the bank structure,
drives the ranking.
The polynomial-filter and filter-bank result JSONs + comparison tables belong to the Track-1 PRs (geometric-intelligence#354, geometric-intelligence#360); they are removed here so this Track-2 branch, opened standalone against main, carries only the shared code it depends on plus the new TNN work -- not another PR's benchmark outputs.
Two spectral simplicial backbones that reuse the graph-side polynomial filter machinery on the Hodge Laplacian: - PolynomialFilterTNN (SNN, Ebli et al. 2020): one polynomial filter in the full Hodge Laplacian L_d per rank; SNN = the Chebyshev basis. The poly_filter bases act on L_d verbatim -- a Basis only ever sees the L_apply closure. laplacian_norm='rescale' hands orthogonal-interval bases (Chebyshev/Jacobi/Legendre) a [0,2]-spectrum operator via a deterministic Gershgorin upper bound on lambda_max; Monomial uses 'raw'. - FilterBankTNN (SCNN, Yang et al. 2022): a two-channel down/up filter bank on L^down_d / L^up_d per rank, reusing Channel + Monomial; SCNN = the Monomial instance. Because L^down L^up = 0 the channels tune the gradient and curl components of the Hodge decomposition independently. Thin simplicial wrappers feed the Hodge operators the batch already exposes (hodge_laplacian_k, down/up_laplacian_k), hardcoding 3 ranks like sccnn_wrapper. Shared helpers in hodge_utils (spmm, deterministic Gershgorin spectral_upper_bound, build_hodge_laplacian_apply). Configs: SNN / Monomial / Jacobi (poly filter) + SCNN (bank). Unit tests (per-rank forward, sparse + empty-rank layouts, basis swap, Gershgorin bound determinism) and the pipeline test now exercise all four on lifted MUTAG.
The SCNN reference wrongly listed five authors (Yang, Sardellitti, Barbarossa, Leus & Isufi) -- that list belongs to "Simplicial Convolutional Filters" (IEEE TSP 2022). The primary SCNN reference is Yang, Isufi & Leus (2022), "Simplicial Convolutional Neural Networks", ICASSP 2022 (arXiv:2110.02585). Also add the arXiv id to the SNN reference (Ebli et al. 2020, arXiv:2010.03633).
Full GraphUniverse sweep (community_detection + triangle_counting, 12 settings x 3 seeds = 72 runs each) for the four simplicial configs, run on Kaggle P100: model CD acc Tri MSE SNN 0.437 +/- 0.114 23140 Monomial 0.390 +/- 0.140 34531 Jacobi 0.432 +/- 0.110 15368 SCNN 0.437 +/- 0.120 11333 Monomial (raw power basis) is worst on both tasks; the SCNN down/up filter bank wins triangle counting by a wide margin (the 2-simplex task the Hodge decomposition is built for). Per-setting detail in results_tnn_*.json; aggregate in comparison_tnn.csv.
Author
|
The PR seems huge, but that's because it's based on two other PRs of mine for the challenge. This PR is a TNN extension to two PR's I made already in the GNN track. I described it in the PR description above. |
Drop the inherited filter-bank GNN entries (those belong to the Track-1 filter-bank PR); this branch's pipeline test should exercise only the simplicial TNN configs it introduces.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Checklist
Thesis
This PR extends the "GNNs/TNNs are polynomial filters in a Laplacian-like operator" program to the topological domain. It contributes two modular simplicial backbones:
PolynomialFilterTNN(a single polynomial filter in the Hodge LaplacianL_k, per rank) andFilterBankTNN(a two-channel down/up filter bank over the Hodge decomposition). They reuse the same swappableBasisregistry as the Track-1 graph backbones, with the graph LaplacianL̃replaced by the Hodge LaplacianL_k. SNN (Ebli et al. 2020) isPolynomialFilterTNNwith the Chebyshev basis; SCNN (Yang et al. 2022) isFilterBankTNNwith the Monomial basis.Scope of this diff — builds on #354 and #360
This PR is stacked on the team's two Track-1 PRs and reuses their graph-side spectral modules on the Hodge Laplacian (a
Basisonly ever sees anL_applyclosure, so the graph bases act onL_kverbatim). Because #354 and #360 are not yet merged intomain, this diff necessarily re-includes their files. But the new contribution here is the 17 simplicial files below; everything else is inherited and reviewed in those PRs.New in this PR
topobench/nn/backbones/simplicial/{poly_filter_tnn, filter_bank_tnn, hodge_utils}.pytopobench/nn/wrappers/simplicial/{poly_filter_tnn, filter_bank_tnn}_wrapper.pyconfigs/model/simplicial/{poly_filter_tnn, poly_filter_tnn_monomial, poly_filter_tnn_jacobi, filter_bank_tnn}.yamltest/nn/backbones/simplicial/test_{poly_filter_tnn, filter_bank_tnn}.py+ the simplicial entries intest/pipeline/test_pipeline.py2026_tdl_challenge/outputs/results_tnn_*.json,comparison_tnn.csvReused (inherited, not new) —
Basis+apply_polynomial_filterfrom #354 (backbones/graph/poly_filter/);Channel+SumFusionfrom #360 (backbones/graph/filter_bank/).What's new relative to what TopoBench/TopoModelX already ship
TopoModelX (a TopoBench dependency) already provides fixed simplicial layers (SCN2, SCCN, SCCNN, SAN), and TopoBench exposes them as configs. This PR is not a re-wrap of those. The contribution is a modular, basis-swappable Hodge-spectral backbone plus two concrete correctness improvements over the existing code:
L_k. TopoModelX'sSCNNLayer.chebyshev_conv(scnn_layer.py:135) is, despite its name, a monomial power iteration (X[:,:,k] = conv_operator @ X[:,:,k-1], i.e.L^k x) with no spectral normalization. Our Chebyshev basis runs the true three-term recurrenceT_k = 2 z T_{k-1} - T_{k-2}inz = L̃ - I, and SNN (a Chebyshev polynomial inL_k) is absent from TopoModelX entirely.λmaxspectral rescale. Orthogonal-interval bases (Chebyshev, Jacobi, Legendre) are only bounded on[-1, 1]; the raw Hodge spectrum is[0, λmax].laplacian_norm='rescale'mapsL_k → 2 L_k / bound(spectrum⊆ [0, 2]) via a deterministic Gershgorin upper bound, so the basis stays inside its domain. Without it, a naive "Chebyshev/Jacobi on the raw Hodge Laplacian" blows up super-exponentially (this is the concrete bug the rescale fixes; pinned bytest_rescale_maps_spectrum_to_0_2).Architecturally,
FilterBankTNNwithMonomial+laplacian_norm='raw'is the TopoModelX SCNN layer's math (per rank down/up monomial powers, γ-fused), modulo one documented redundant identity term — so it is a faithful, first-in TopoBench runnable SCNN, and swapping the basis or turning onrescalegeneralizes it to the higher-order spectral filters TopoModelX cannot express.Design rationale — why two backbones, one registry
A single polynomial filter and a filter bank are structurally different forward passes (
Σ_k θ_k T_k(L_k) xvsfuse_q γ_q g_q(L_k) x), so they are two backbones — but both are operator-agnostic: aBasisonly ever sees a closureL_apply : h ↦ L hand never the domain. That single interface decision is what lets the graph bases (Monomial, Chebyshev, Jacobi, Legendre, …) act on the Hodge Laplacian verbatim — the simplicial backbones importapply_polynomial_filter,Channel, and the basis modules from the graph side unchanged. Adding a new basis is a single-file change on both domains at once.Taxonomy placement
The registered bases are the Hodge-Laplacian instantiations of the same Liao et al. (2024, SIGMOD, Appendix B) polynomial families used in Track 1:
simplicial/poly_filter_tnnPolynomialFilterTNNsimplicial/poly_filter_tnn_monomialPolynomialFilterTNNsimplicial/poly_filter_tnn_jacobiPolynomialFilterTNNL_ksimplicial/filter_bank_tnnFilterBankTNNPer-model citations
L_kper rank.L^down_k/L^up_k, fused — the Hodge-decomposition filter bank.Evaluation — GraphUniverse sweep (12 settings × 3 seeds)
Full challenge grid (community detection, node accuracy ↑; triangle counting, MSE ↓), mean ± std over the homophily/degree/cluster-variance families:
Two findings that track the theory:
L_k.FilterBankTNNis the only model here that tunes the curl (L^up) and gradient (L^down) components independently — so the architecture wins exactly where the Hodge decomposition predicts it should.Baseline note. The GraphUniverse paper (Van Langendonck et al. 2026) reports topological baselines only for TopoTune and Neural Sheaf Diffusion (not the SCNN/SCN/SNN family), and uses normalized MAE for triangle counting, so no published number is directly comparable to the SCNN/SNN-family MSE results above.
simplicial/topotuneis a TopoBench config and can be run on this exact grid for a same-protocol comparison if reviewers want one.Scope / what this PR does not do
apply_polynomial_filter,Channel, bases) fromgraph/; an optional follow-up could be to move it to a domain-neutralspectral/module. This shouldn't change any behavior.λmaxbound. The Gershgorin rescale uses one bound per operator; documented, and safe (guaranteed⊆ [0, 2], may under-fill).Tests
15 new unit tests (per-rank forward, sparse + empty-rank layouts, basis swap on
L_k, deterministic Gershgorin bound) + all four configs added to the pipeline test (end-to-end on auto-lifted MUTAG). ruff + numpydoc clean.