From 2eea25ef53487458e0434c53e92b356af6f3dd4b Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 2 Jul 2026 10:42:05 -0400 Subject: [PATCH 1/6] Route non-abelian sector spaces to a native TensorKit space `to_range` on `sector => multiplicity` pairs now dispatches on symmetry. Abelian sectors keep the block-sparse `GradedOneTo` backend. Non-abelian sectors build a native TensorKit `GradedSpace` via a new `GradedArraysTensorKitExt`, with the arrow riding inside the space. Co-Authored-By: Claude Opus 4.8 (1M context) --- Project.toml | 5 ++- .../GradedArraysTensorKitExt.jl | 24 +++++++++++ src/gradedoneto.jl | 33 ++++++++++++++- test/Project.toml | 2 + test/test_tensorkitext.jl | 40 +++++++++++++++++++ 5 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl create mode 100644 test/test_tensorkitext.jl diff --git a/Project.toml b/Project.toml index f0b72efe..cbaf99c5 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "GradedArrays" uuid = "bc96ca6e-b7c8-4bb6-888e-c93f838762c2" -version = "0.13.5" +version = "0.13.6" authors = ["ITensor developers and contributors"] [workspace] @@ -24,9 +24,11 @@ TensorKitSectors = "13a9c161-d5da-41f0-bcbd-e1a08ae0647f" [weakdeps] SUNRepresentations = "1a50b95c-7aac-476d-a9ce-2bfc675fc617" +TensorKit = "07d1fe3e-3e46-537d-9eac-e9e13d0d4cec" [extensions] GradedArraysSUNRepresentationsExt = "SUNRepresentations" +GradedArraysTensorKitExt = "TensorKit" [compat] ArrayLayouts = "1" @@ -43,5 +45,6 @@ SparseArraysBase = "0.10" SplitApplyCombine = "1.2.3" StridedViews = "0.5" TensorAlgebra = "0.16" +TensorKit = "0.17" TensorKitSectors = "0.3" julia = "1.10" diff --git a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl new file mode 100644 index 00000000..e20c6439 --- /dev/null +++ b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl @@ -0,0 +1,24 @@ +module GradedArraysTensorKitExt + +using GradedArrays: GradedArrays, SectorRange, isdual, label +using TensorKit: TensorKit, Vect + +# Build a native TensorKit `GradedSpace` from non-abelian `sector => multiplicity` pairs. +# `GradedArrays.to_range` routes non-abelian sectors here (they have no block-sparse +# `GradedOneTo` representation). The sector's arrow rides inside the space, matching the +# TensorKit convention: a shared dual flag across the pairs becomes a dual space. Raw +# `TKS.Sector` keys are normalized to `SectorRange` so both key kinds are handled. +function GradedArrays.to_tensorkit_space(space::AbstractVector{<:Pair}) + reps = _to_sectorrange.(first.(space)) + d = isdual(first(reps)) + all(r -> isdual(r) == d, reps) || + throw(ArgumentError("All sectors must have the same isdual flag")) + irreptype = typeof(label(first(reps))) + V = Vect[irreptype]((label(r) => m for (r, m) in zip(reps, last.(space)))...) + return d ? TensorKit.dual(V) : V +end + +_to_sectorrange(r::SectorRange) = r +_to_sectorrange(c) = SectorRange(c) + +end diff --git a/src/gradedoneto.jl b/src/gradedoneto.jl index 7b636b36..6ea5d0ee 100644 --- a/src/gradedoneto.jl +++ b/src/gradedoneto.jl @@ -263,9 +263,38 @@ end # Build a graded range from a vector of sector-to-multiplicity pairs, e.g. # `to_range([U1(0) => 2, U1(1) => 3])`. Defined over each key type separately # rather than a `Union` so each method stays specific enough not to capture -# unrelated `Pair` vectors. +# unrelated `Pair` vectors. The result is routed by symmetry: abelian sectors +# build a block-sparse `GradedOneTo`; non-abelian sectors have no block-sparse +# representation and instead build a native TensorKit `GradedSpace` via +# `to_tensorkit_space` (provided by the GradedArrays–TensorKit extension). for S in (:(TKS.Sector), :SectorRange) @eval function TensorAlgebra.to_range(space::AbstractVector{<:Pair{<:$S, <:Integer}}) - return gradedrange(space) + isempty(space) && return gradedrange(space) + return _to_range(_sectorstyle(eltype(space)), space) end end + +# `SymmetryStyle` of a sector-pair key, normalizing a raw `TKS.Sector` to `SectorRange` +# (whose `SymmetryStyle` consults the fusion rule) so both key kinds route identically. +_sectorstyle(::Type{<:Pair{K}}) where {K <: SectorRange} = SymmetryStyle(K) +_sectorstyle(::Type{<:Pair{K}}) where {K <: TKS.Sector} = SymmetryStyle(SectorRange{K}) + +_to_range(::AbelianStyle, space) = gradedrange(space) +_to_range(::NotAbelianStyle, space) = to_tensorkit_space(space) + +""" + to_tensorkit_space(sectors) + +Convert a vector of non-abelian `sector => multiplicity` pairs into a native TensorKit +`GradedSpace`. Non-abelian symmetries have no block-sparse (`GradedOneTo`) representation, +so `to_range` routes them here. Requires TensorKit to be loaded; the method that builds the +space lives in the GradedArrays–TensorKit extension. +""" +function to_tensorkit_space(space) + return throw( + ArgumentError( + "building a native non-abelian graded space from $(space) requires TensorKit; \ + run `using TensorKit` to enable it" + ) + ) +end diff --git a/test/Project.toml b/test/Project.toml index fd26809f..1038a3d1 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -12,6 +12,7 @@ SafeTestsets = "1bc83da4-3b8d-516f-aca4-4fe02f6d838f" SparseArraysBase = "0d5efcca-f356-4864-8770-e1ed8d78f208" Suppressor = "fd094767-a336-5f1f-9728-57cf17d0bbfb" TensorAlgebra = "68bd88dc-f39d-4e12-b2ca-f046b68fcc6a" +TensorKit = "07d1fe3e-3e46-537d-9eac-e9e13d0d4cec" TensorKitSectors = "13a9c161-d5da-41f0-bcbd-e1a08ae0647f" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" TestExtras = "5ed8adda-3752-4e41-b88a-e8b09835ee3a" @@ -33,6 +34,7 @@ SafeTestsets = "0.1" SparseArraysBase = "0.10" Suppressor = "0.2.8" TensorAlgebra = "0.16" +TensorKit = "0.17" TensorKitSectors = "0.3" Test = "1.10" TestExtras = "0.3.1" diff --git a/test/test_tensorkitext.jl b/test/test_tensorkitext.jl new file mode 100644 index 00000000..6de60a4a --- /dev/null +++ b/test/test_tensorkitext.jl @@ -0,0 +1,40 @@ +using GradedArrays: GradedOneTo, SU2, U1, dual, gradedrange +using TensorAlgebra: to_range +using TensorKit: TensorKit, GradedSpace, SU2Irrep, Vect, dim +using Test: @test, @test_throws, @testset + +# `to_range` on `sector => multiplicity` pairs is routed by symmetry. Abelian sectors keep +# the block-sparse `GradedOneTo` backend; non-abelian sectors have no block-sparse +# representation and build a native TensorKit `GradedSpace` (this extension). This lets +# `Index([SU2(0) => 1, …])` reach the same `TensorMap`-backed `ITensor` as passing a native +# TensorKit space to `Index`. +@testset "GradedArraysTensorKitExt" begin + # Abelian sectors are untouched by the extension: still a block-sparse `GradedOneTo`. + @testset "abelian stays block-sparse" begin + r = to_range([U1(0) => 2, U1(1) => 3]) + @test r isa GradedOneTo + @test !(r isa GradedSpace) + @test r == gradedrange([U1(0) => 2, U1(1) => 3]) + end + + # Non-abelian sectors build a native TensorKit `GradedSpace` equal to the one produced + # by TensorKit's own constructor. + @testset "non-abelian builds a native GradedSpace" begin + r = to_range([SU2(0) => 1, SU2(1) => 2]) + @test r isa GradedSpace + @test r == Vect[SU2Irrep](0 => 1, 1 => 2) + @test dim(r) == 1 * 1 + 3 * 2 + end + + # The sector arrow rides inside the space: a shared dual flag makes a dual space. + @testset "dual arrow rides inside the space" begin + r = to_range([dual(SU2(0)) => 1, dual(SU2(1)) => 2]) + @test r isa GradedSpace + @test r == TensorKit.dual(Vect[SU2Irrep](0 => 1, 1 => 2)) + end + + # A native TensorKit space has a single arrow, so mixed arrows have no representation. + @testset "mixed arrows error" begin + @test_throws ArgumentError to_range([SU2(0) => 1, dual(SU2(1)) => 2]) + end +end From b57fcc6ad5f79e1825cec96106b9f6b319dcad14 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 2 Jul 2026 11:41:21 -0400 Subject: [PATCH 2/6] Inline the sector-symmetry routing in to_range Fold the `_sectorstyle`/`_to_range` helpers into two `to_range` methods that dispatch on `SymmetryStyle` inline, and drop the `isempty` fallback that wrongly routed empty non-abelian pairs to `gradedrange`. Add an idempotent `SectorRange(::SectorRange)` constructor so the extension normalizes keys without its own helper. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GradedArraysTensorKitExt.jl | 5 +-- src/gradedoneto.jl | 40 ++++++++++--------- src/sectorrange.jl | 1 + 3 files changed, 24 insertions(+), 22 deletions(-) diff --git a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl index e20c6439..eed3c5fe 100644 --- a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl +++ b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl @@ -9,7 +9,7 @@ using TensorKit: TensorKit, Vect # TensorKit convention: a shared dual flag across the pairs becomes a dual space. Raw # `TKS.Sector` keys are normalized to `SectorRange` so both key kinds are handled. function GradedArrays.to_tensorkit_space(space::AbstractVector{<:Pair}) - reps = _to_sectorrange.(first.(space)) + reps = SectorRange.(first.(space)) d = isdual(first(reps)) all(r -> isdual(r) == d, reps) || throw(ArgumentError("All sectors must have the same isdual flag")) @@ -18,7 +18,4 @@ function GradedArrays.to_tensorkit_space(space::AbstractVector{<:Pair}) return d ? TensorKit.dual(V) : V end -_to_sectorrange(r::SectorRange) = r -_to_sectorrange(c) = SectorRange(c) - end diff --git a/src/gradedoneto.jl b/src/gradedoneto.jl index 6ea5d0ee..46b3035e 100644 --- a/src/gradedoneto.jl +++ b/src/gradedoneto.jl @@ -261,26 +261,30 @@ function gradedrange( end # Build a graded range from a vector of sector-to-multiplicity pairs, e.g. -# `to_range([U1(0) => 2, U1(1) => 3])`. Defined over each key type separately -# rather than a `Union` so each method stays specific enough not to capture -# unrelated `Pair` vectors. The result is routed by symmetry: abelian sectors -# build a block-sparse `GradedOneTo`; non-abelian sectors have no block-sparse -# representation and instead build a native TensorKit `GradedSpace` via -# `to_tensorkit_space` (provided by the GradedArrays–TensorKit extension). -for S in (:(TKS.Sector), :SectorRange) - @eval function TensorAlgebra.to_range(space::AbstractVector{<:Pair{<:$S, <:Integer}}) - isempty(space) && return gradedrange(space) - return _to_range(_sectorstyle(eltype(space)), space) +# `to_range([U1(0) => 2, U1(1) => 3])`, routed by symmetry: abelian sectors build a +# block-sparse `GradedOneTo`, while non-abelian sectors have no block-sparse representation +# and build a native TensorKit `GradedSpace` via `to_tensorkit_space`. Defined over each +# key type separately rather than a `Union` so each method stays specific enough not to +# capture unrelated `Pair` vectors. A raw `TKS.Sector` key is wrapped in `SectorRange` so +# `SymmetryStyle` consults the fusion rule rather than its `AbelianStyle` default. +function TensorAlgebra.to_range( + space::AbstractVector{<:Pair{K, <:Integer}} + ) where {K <: SectorRange} + return if SymmetryStyle(K) === AbelianStyle() + gradedrange(space) + else + to_tensorkit_space(space) + end +end +function TensorAlgebra.to_range( + space::AbstractVector{<:Pair{K, <:Integer}} + ) where {K <: TKS.Sector} + return if SymmetryStyle(SectorRange{K}) === AbelianStyle() + gradedrange(space) + else + to_tensorkit_space(space) end end - -# `SymmetryStyle` of a sector-pair key, normalizing a raw `TKS.Sector` to `SectorRange` -# (whose `SymmetryStyle` consults the fusion rule) so both key kinds route identically. -_sectorstyle(::Type{<:Pair{K}}) where {K <: SectorRange} = SymmetryStyle(K) -_sectorstyle(::Type{<:Pair{K}}) where {K <: TKS.Sector} = SymmetryStyle(SectorRange{K}) - -_to_range(::AbelianStyle, space) = gradedrange(space) -_to_range(::NotAbelianStyle, space) = to_tensorkit_space(space) """ to_tensorkit_space(sectors) diff --git a/src/sectorrange.jl b/src/sectorrange.jl index dfdf00a1..db0ef9c7 100644 --- a/src/sectorrange.jl +++ b/src/sectorrange.jl @@ -13,6 +13,7 @@ struct SectorRange{I <: TKS.Sector} <: AbstractUnitRange{Int} end SectorRange{I}(label) where {I} = SectorRange{I}(label, false) SectorRange(label::TKS.Sector) = SectorRange(label, false) +SectorRange(r::SectorRange) = r label(r::SectorRange) = r.label isdual(r::SectorRange) = r.isdual From 1fca9717f8500671edf61f7be9d608e0eb71f5ba Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 2 Jul 2026 11:54:14 -0400 Subject: [PATCH 3/6] Generalize SymmetryStyle to raw sectors and collapse the to_range methods Define `SymmetryStyle` directly on `TensorKitSectors.Sector` via the fusion rule and have `SectorRange` delegate to it, so both sector-pair `to_range` methods share one body that dispatches on `SymmetryStyle` inline. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gradedoneto.jl | 28 ++++++++++------------------ src/sectorrange.jl | 9 +++------ 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/gradedoneto.jl b/src/gradedoneto.jl index 46b3035e..4cffc4e6 100644 --- a/src/gradedoneto.jl +++ b/src/gradedoneto.jl @@ -265,24 +265,16 @@ end # block-sparse `GradedOneTo`, while non-abelian sectors have no block-sparse representation # and build a native TensorKit `GradedSpace` via `to_tensorkit_space`. Defined over each # key type separately rather than a `Union` so each method stays specific enough not to -# capture unrelated `Pair` vectors. A raw `TKS.Sector` key is wrapped in `SectorRange` so -# `SymmetryStyle` consults the fusion rule rather than its `AbelianStyle` default. -function TensorAlgebra.to_range( - space::AbstractVector{<:Pair{K, <:Integer}} - ) where {K <: SectorRange} - return if SymmetryStyle(K) === AbelianStyle() - gradedrange(space) - else - to_tensorkit_space(space) - end -end -function TensorAlgebra.to_range( - space::AbstractVector{<:Pair{K, <:Integer}} - ) where {K <: TKS.Sector} - return if SymmetryStyle(SectorRange{K}) === AbelianStyle() - gradedrange(space) - else - to_tensorkit_space(space) +# capture unrelated `Pair` vectors. +for S in (:(TKS.Sector), :SectorRange) + @eval function TensorAlgebra.to_range( + space::AbstractVector{<:Pair{K, <:Integer}} + ) where {K <: $S} + return if SymmetryStyle(K) === AbelianStyle() + gradedrange(space) + else + to_tensorkit_space(space) + end end end diff --git a/src/sectorrange.jl b/src/sectorrange.jl index db0ef9c7..8a6d3a31 100644 --- a/src/sectorrange.jl +++ b/src/sectorrange.jl @@ -142,13 +142,10 @@ SymmetryStyle(x) = SymmetryStyle(typeof(x)) # allows for abelian-like slicing style for GradedOneTo: assume length(::label) = 1 # and preserve labels in any slicing operation SymmetryStyle(T::Type) = AbelianStyle() -function SymmetryStyle(::Type{T}) where {T <: SectorRange} - if TKS.FusionStyle(T) === TKS.UniqueFusion() - return AbelianStyle() - else - return NotAbelianStyle() - end +function SymmetryStyle(::Type{T}) where {T <: TKS.Sector} + return TKS.FusionStyle(T) === TKS.UniqueFusion() ? AbelianStyle() : NotAbelianStyle() end +SymmetryStyle(::Type{SectorRange{I}}) where {I} = SymmetryStyle(I) SymmetryStyle(G::Type{<:AbstractUnitRange}) = SymmetryStyle(sectortype(G)) combine_styles(::AbelianStyle, ::AbelianStyle) = AbelianStyle() From 3b3322d2e13e8bca5b3b6dafbfd4a398076a9121 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 2 Jul 2026 11:57:07 -0400 Subject: [PATCH 4/6] Split to_tensorkit_space into a pure-TensorKit inner layer The outer method normalizes keys to `SectorRange` and reads off the shared arrow, then hands the sector labels and that flag to `_to_tensorkit_space`, which builds the `GradedSpace` entirely in TensorKit terms. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GradedArraysTensorKitExt.jl | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl index eed3c5fe..733877f2 100644 --- a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl +++ b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl @@ -5,17 +5,26 @@ using TensorKit: TensorKit, Vect # Build a native TensorKit `GradedSpace` from non-abelian `sector => multiplicity` pairs. # `GradedArrays.to_range` routes non-abelian sectors here (they have no block-sparse -# `GradedOneTo` representation). The sector's arrow rides inside the space, matching the -# TensorKit convention: a shared dual flag across the pairs becomes a dual space. Raw -# `TKS.Sector` keys are normalized to `SectorRange` so both key kinds are handled. +# `GradedOneTo` representation). This layer works in GradedArrays terms: normalize keys to +# `SectorRange` (a raw `TKS.Sector` is non-dual) and read off the shared arrow, then hand the +# sector labels and that arrow to the pure-TensorKit builder. function GradedArrays.to_tensorkit_space(space::AbstractVector{<:Pair}) reps = SectorRange.(first.(space)) d = isdual(first(reps)) all(r -> isdual(r) == d, reps) || throw(ArgumentError("All sectors must have the same isdual flag")) - irreptype = typeof(label(first(reps))) - V = Vect[irreptype]((label(r) => m for (r, m) in zip(reps, last.(space)))...) - return d ? TensorKit.dual(V) : V + return _to_tensorkit_space([label(r) => m for (r, m) in zip(reps, last.(space))], d) +end + +# Pure TensorKit: the keys are sector labels and the arrow is a flag. A raw sector carries no +# duality, so the arrow rides inside the space as a whole-space `dual(V)` (distinct from a +# space of dual sectors, and the form a dual index must take for contraction). +function _to_tensorkit_space( + space::AbstractVector{<:Pair{S}}, + isdual::Bool + ) where {S <: TensorKit.Sector} + V = Vect[S](space...) + return isdual ? TensorKit.dual(V) : V end end From b708f3c25a689800439fde91dbc2542661fb19f0 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 2 Jul 2026 12:07:52 -0400 Subject: [PATCH 5/6] Move the SectorRange to_tensorkit_space routing into the library The `SectorRange`-keyed `to_tensorkit_space` method now lives in GradedArrays proper: it strips the sector labels, checks that the pairs share one arrow, builds the non-dual space, and flips it with `dual` when the arrow is dual. Only the label-keyed TensorKit `GradedSpace` builder and a `dual` bridge on TensorKit spaces stay in the extension, so the routing logic is expressed once in GradedArrays terms and the extension holds just the TensorKit specifics. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../GradedArraysTensorKitExt.jl | 37 +++++++------------ src/gradedoneto.jl | 13 +++++++ test/test_tensorkitext.jl | 8 ++++ 3 files changed, 35 insertions(+), 23 deletions(-) diff --git a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl index 733877f2..b84ea4e4 100644 --- a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl +++ b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl @@ -1,30 +1,21 @@ module GradedArraysTensorKitExt -using GradedArrays: GradedArrays, SectorRange, isdual, label -using TensorKit: TensorKit, Vect +using GradedArrays: GradedArrays +using TensorKit: TensorKit, ElementarySpace, Vect -# Build a native TensorKit `GradedSpace` from non-abelian `sector => multiplicity` pairs. -# `GradedArrays.to_range` routes non-abelian sectors here (they have no block-sparse -# `GradedOneTo` representation). This layer works in GradedArrays terms: normalize keys to -# `SectorRange` (a raw `TKS.Sector` is non-dual) and read off the shared arrow, then hand the -# sector labels and that arrow to the pure-TensorKit builder. -function GradedArrays.to_tensorkit_space(space::AbstractVector{<:Pair}) - reps = SectorRange.(first.(space)) - d = isdual(first(reps)) - all(r -> isdual(r) == d, reps) || - throw(ArgumentError("All sectors must have the same isdual flag")) - return _to_tensorkit_space([label(r) => m for (r, m) in zip(reps, last.(space))], d) -end - -# Pure TensorKit: the keys are sector labels and the arrow is a flag. A raw sector carries no -# duality, so the arrow rides inside the space as a whole-space `dual(V)` (distinct from a -# space of dual sectors, and the form a dual index must take for contraction). -function _to_tensorkit_space( - space::AbstractVector{<:Pair{S}}, - isdual::Bool +# Non-abelian `sector => multiplicity` pairs have no block-sparse `GradedOneTo` representation, +# so `GradedArrays.to_range` routes them here to build a native TensorKit `GradedSpace`. A raw +# TensorKit sector carries no arrow, so this is the non-dual builder. It is the entry point +# both for the `SectorRange` routing in GradedArrays and for a user-supplied list of TensorKit +# sectors passed to `to_range`. +function GradedArrays.to_tensorkit_space( + space::AbstractVector{<:Pair{S}} ) where {S <: TensorKit.Sector} - V = Vect[S](space...) - return isdual ? TensorKit.dual(V) : V + return Vect[S](space...) end +# A TensorKit space is a first-class graded axis under the direct-wrap design, so `dual` on +# one flips its arrow. This is the whole-space dual the `SectorRange` routing applies. +GradedArrays.dual(V::ElementarySpace) = TensorKit.dual(V) + end diff --git a/src/gradedoneto.jl b/src/gradedoneto.jl index 4cffc4e6..34282e13 100644 --- a/src/gradedoneto.jl +++ b/src/gradedoneto.jl @@ -294,3 +294,16 @@ function to_tensorkit_space(space) ) ) end + +# `SectorRange` sector-pairs carry an arrow: strip to sector labels, check the shared arrow, +# and apply it as a whole-space `dual` (distinct from a space of dual sectors, and the form a +# dual index must take for contraction). The label-keyed `to_tensorkit_space` builder and +# `dual` on the resulting space come from the GradedArrays–TensorKit extension. +function to_tensorkit_space(space::AbstractVector{<:Pair{S}}) where {S <: SectorRange} + arrows = isdual.(first.(space)) + allequal(arrows) || + throw(ArgumentError("All sectors must have the same isdual flag")) + labels_space = [label(first(p)) => last(p) for p in space] + nondual_space = to_tensorkit_space(labels_space) + return first(arrows) ? dual(nondual_space) : nondual_space +end diff --git a/test/test_tensorkitext.jl b/test/test_tensorkitext.jl index 6de60a4a..2e8b75ba 100644 --- a/test/test_tensorkitext.jl +++ b/test/test_tensorkitext.jl @@ -26,6 +26,14 @@ using Test: @test, @test_throws, @testset @test dim(r) == 1 * 1 + 3 * 2 end + # A raw list of TensorKit sectors (no `SectorRange` wrapper) is a valid input and builds + # the same non-dual space. + @testset "raw TensorKit sectors" begin + r = to_range([SU2Irrep(0) => 1, SU2Irrep(1) => 2]) + @test r isa GradedSpace + @test r == Vect[SU2Irrep](0 => 1, 1 => 2) + end + # The sector arrow rides inside the space: a shared dual flag makes a dual space. @testset "dual arrow rides inside the space" begin r = to_range([dual(SU2(0)) => 1, dual(SU2(1)) => 2]) From 4e86a3c012434a9e3be87b5d5d0619b08ba83a0e Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 2 Jul 2026 12:22:38 -0400 Subject: [PATCH 6/6] Build the TensorKit space from the pairs without splatting `Vect[S]` accepts the `sector => multiplicity` pairs as a single iterable, so pass the vector directly rather than splatting it. Splatting a long sector list builds a large tuple and dispatches through varargs, which the iterable constructor avoids. Co-Authored-By: Claude Opus 4.8 (1M context) --- ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl index b84ea4e4..e9e2d1c0 100644 --- a/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl +++ b/ext/GradedArraysTensorKitExt/GradedArraysTensorKitExt.jl @@ -7,11 +7,12 @@ using TensorKit: TensorKit, ElementarySpace, Vect # so `GradedArrays.to_range` routes them here to build a native TensorKit `GradedSpace`. A raw # TensorKit sector carries no arrow, so this is the non-dual builder. It is the entry point # both for the `SectorRange` routing in GradedArrays and for a user-supplied list of TensorKit -# sectors passed to `to_range`. +# sectors passed to `to_range`. `Vect[S]` takes the pairs as a single iterable (rather than +# splatting), so a long sector list does not build a large tuple or hit vararg dispatch. function GradedArrays.to_tensorkit_space( space::AbstractVector{<:Pair{S}} ) where {S <: TensorKit.Sector} - return Vect[S](space...) + return Vect[S](space) end # A TensorKit space is a first-class graded axis under the direct-wrap design, so `dual` on