Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
7d352d2
implement testsuite
borisdevos Jul 6, 2026
d9cc856
apply testsuite to current tests
borisdevos Jul 6, 2026
f469892
use TKS's testsuite for sector utility
borisdevos Jul 6, 2026
bad9878
homogenise eval_show
borisdevos Jul 6, 2026
5f5e313
Merge branch 'main' of https://github.com/Jutho/TensorKit.jl into bd/…
borisdevos Jul 6, 2026
91127f9
avoid testsuite being recognised as tests
borisdevos Jul 7, 2026
cbaf351
fix x ambiguity
borisdevos Jul 7, 2026
10f8a4e
move testsuite file
borisdevos Jul 7, 2026
eff5ab6
make separate entry points for single and double fusion tree tests
borisdevos Jul 7, 2026
77c2061
split up tensor tests
borisdevos Jul 8, 2026
e1d0f33
make diagonal tensor tests also an entry point
borisdevos Jul 8, 2026
cb8d067
convert braiding tensor properties testset to testsuite
borisdevos Jul 8, 2026
df01132
have single access function to run testsuites + changes related to mo…
borisdevos Jul 8, 2026
a7e4b4a
add factorizations to the testsuite
borisdevos Jul 8, 2026
622c960
Merge branches 'bd/testsuite' and 'main' of https://github.com/Quantu…
borisdevos Jul 8, 2026
3fbf44e
typos and exports
borisdevos Jul 9, 2026
845406d
Merge branch 'main' of https://github.com/QuantumKitHub/TensorKit.jl …
borisdevos Jul 9, 2026
b9eea56
more import silliness
borisdevos Jul 9, 2026
42d7490
workaround to fast_tests external injection
borisdevos Jul 9, 2026
1adfa95
scoping is hard
borisdevos Jul 10, 2026
1712178
update test readme
borisdevos Jul 10, 2026
c8cc738
Merge branch 'main' of https://github.com/QuantumKitHub/TensorKit.jl …
borisdevos Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions test/TensorKitTestSuite.jl
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
"""
module TensorKitTestSuite

Test suite and utilities that ensure a reusable way of verifying that a custom `Sector`
type is correctly supported by fusion trees, `GradedSpace`, and `TensorMap`.

Downstream packages may include this test suite as follows:

```julia
import TensorKit
testsuite_path = joinpath(
dirname(dirname(pathof(TensorKit))), # TensorKit root
"test", "TensorKitTestSuite.jl"
)
include(testsuite_path)
using .TensorKitTestSuite

TensorKitTestSuite.test_fusiontrees(MySector)
TensorKitTestSuite.test_spaces(MySector)
TensorKitTestSuite.test_tensors((V1, V2, V3, V4, V5)) # 5 mutually compatible spaces
```

The three entry points above are independent and may be run selectively.
This module additionally exports:
* [`force_planar`](@ref)
* [`eval_show`](@ref)

Sector-level helpers are reused from `TensorKitSectors.SectorTestSuite` internally,
but deliberately *not* re-exported here.
"""
module TensorKitTestSuite

export test_fusiontrees, test_spaces, test_tensors
export force_planar, eval_show

using Test
using TestExtras
using Random
using Random: randperm
using LinearAlgebra
using TupleTools
using Combinatorics: permutations
using TensorOperations
using MatrixAlgebraKit: left_polar, isunitary

using TensorKit
using TensorKit: type_repr, FusionTreeBlock, ℙ, PlanarTrivial, hassector, HomSpace
import TensorKit as TK
using TensorKitSectors
using TensorKitSectors: ×

# Reuse TensorKitSectors's own sector-level test helpers
sectortestsuite_path = joinpath(
dirname(dirname(pathof(TensorKitSectors))), "test", "testsuite.jl"
)
include(sectortestsuite_path)
using .SectorTestSuite: smallset, randsector, hasfusiontensor
using .SectorTestSuite: can_fuse, F_unitarity_test, R_unitarity_test
import .SectorTestSuite: random_fusion # TODO: is the method added below needed?

const testgroups = Dict{Symbol, Dict{String, Expr}}(
:fusiontrees => Dict{String, Expr}(),
:spaces => Dict{String, Expr}(),
:tensors => Dict{String, Expr}(),
)

# cannot just esc() the body, because that would make it a closure, compile it at a fixed world age and break constprop=true
# workaround here is to store an unevaluated `Expr` and `Core.eval` it in a fresh `let` block every time
# this is at the cost of not reusing compiled code
function _run_testsuite_entry(lambda::Expr, arg)
param, body = lambda.args[1], lambda.args[2]
letex = Expr(:let, Expr(:(=), param, arg), body) # let block makes param local to the body
return Core.eval(@__MODULE__, letex)
end

"""
@testsuite testgroup name I -> begin
# test code here
end

Register a testsuite entry under `testgroup` (one of `:fusiontrees`, `:spaces`,`:tensors`).
The body is executed with a single argument: the concrete `Sector` type
under test (for `:fusiontrees`/`:spaces`), or a 5-tuple/vector of mutually compatible spaces (for `:tensors`).

Important: Whatever is passed as `name` becomes part of the generated function that must be called to run that body.
In particular, a `safe_name` is made where `name`'s spaces are replaced by underscores, and everything becomes lowercase.
One then calls `test_<testgroup>_<safe_name>`. This way, individual entries can be invoked without running the whole test group.
"""
macro testsuite(testgroup, name, ex)
Meta.isexpr(ex, :(->)) || error("@testsuite requires an `arg -> body` expression")
testgroupsym = testgroup isa QuoteNode ? testgroup.value : testgroup
safe_name = lowercase(replace(name, r"[^A-Za-z0-9]+" => "_"))
fn = Symbol("test_", testgroupsym, "_", safe_name)
group = QuoteNode(testgroupsym)
return quote
@assert !haskey(testgroups[$group], $name) "duplicate testsuite name: $($name) ($($group))"
testgroups[$group][$name] = $(QuoteNode(ex))
$(esc(fn))(arg) = _run_testsuite_entry(testgroups[$group][$name], arg)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm slightly confused by having both a function and an expression stored, it might be simpler to just have a single access function that takes a group and name instead?

function run_testsuite(group, name, args...)
    _run_testsuite_entry(testgroups[group][name], args...)
end

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't think about this, but thinking about a benefit in hindsight, having the function and expression stored makes the different tests more discoverable. Typing something like TensorKitTestSuite.test_fusiontrees_<TAB> doesn't require you to know the string lookup.

Of course, your alternative will precisely avoid cluttering the namespace with similarly named functions. Thinking about it a bit more, your suggestion is in my opinion also easier to read, and removing the safe name allows for more flexible names for the tests. So I'll do this and we'll see how it looks.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing to mention is that we don't actually have to do this in a testsuite-style like TensorKitSectors, if the goal is really to simply have the option to call some of these functions we can also just have a module that defines these tests as functions immediately, without needing a macro.

nothing
end
end

"""
test_fusiontrees(I::Type{<:Sector})

Runs the entire fusion-tree manipulation test suite (single and double fusion trees)
on sector type `I`.
"""
function test_fusiontrees(I::Type{<:Sector})
return @testset "$(type_repr(I))" begin
for (name, lambda) in testgroups[:fusiontrees]
@testset "$name" begin
_run_testsuite_entry(lambda, I)
end
end
end
end

"""
test_spaces(I::Type{<:Sector})

Runs the `GradedSpace` test suite on sector type `I`.
"""
function test_spaces(I::Type{<:Sector}) #TODO: change since it's just 1 testsuite, or change to get the hom-space tests in here
return @testset "$(type_repr(I))" begin
for (name, lambda) in testgroups[:spaces]
@testset "$name" begin
_run_testsuite_entry(lambda, I)
end
end
end
end

"""
test_tensors(V)

Runs the tensor operation test suite (construction, contractions, linear algebra,
index manipulations, braiding, `HomSpace`) on `V`, a 5-tuple of mutually
compatible `ElementarySpace`s. See `setup.jl` for space design considerations.
"""
function test_tensors(V::NTuple{5, GradedSpace{I, NTuple{N, Int}}}) where {I <: Sector, N}
return @testset "$(type_repr(I))" begin
for (name, lambda) in testgroups[:tensors]
@testset "$name" begin
_run_testsuite_entry(lambda, V)
end
end
end
end

# Sector utilities
# ----------------
"""
random_fusion(I::Type{<:Sector}, ::Val{N}) where {N}

Returns an `NTuple{N,I}` of sectors that can consistently be used as the uncoupled
sectors of a fusion tree, i.e. consecutive sectors have a non-empty fusion product.
Thin wrapper around `SectorTestSuite.random_fusion(I, N::Int)`.
"""
function random_fusion(I::Type{<:Sector}, ::Val{N}) where {N}
v = random_fusion(I, N)
return ntuple(i -> v[i], Val(N))
end

"""
force_planar(obj)

Replace an object with a planar equivalent -- i.e. one that disallows braiding.
"""
force_planar(V::ComplexSpace) = isdual(V) ? (ℙ^dim(V))' : ℙ^dim(V)
function force_planar(V::GradedSpace)
return GradedSpace((c ⊠ PlanarTrivial() => dim(V, c) for c in sectors(V))..., isdual(V))
end
force_planar(V::ProductSpace) = mapreduce(force_planar, ⊗, V)
function force_planar(tsrc::TensorMap{<:Any, ComplexSpace})
tdst = similar(tsrc, force_planar(codomain(tsrc)) ← force_planar(domain(tsrc)))
copyto!(block(tdst, PlanarTrivial()), block(tsrc, Trivial()))
return tdst
end
function force_planar(tsrc::TensorMap{<:Any, <:GradedSpace})
tdst = similar(tsrc, force_planar(codomain(tsrc)) ← force_planar(domain(tsrc)))
for (c, b) in blocks(tsrc)
copyto!(block(tdst, c ⊠ PlanarTrivial()), b)
end
return tdst
end

# # helper function to check that d - dim(c) < dim(V) <= d where c is the largest sector
# # to allow for truncations to have some margin with larger sectors
# function dim_isapprox(V::ElementarySpace, d::Int)
# dim_c_max = maximum(dim, sectors(V); init = 1)
# return max(0, d - dim_c_max) ≤ dim(V) ≤ d + dim_c_max
# end
# function dim_isapprox(V::ProductSpace, d::Int)
# dim_c_max = maximum(dim, blocksectors(V); init = 1)
# return max(0, d - dim_c_max) ≤ dim(V) ≤ d + dim_c_max
# end

_isunitary(x::Number; kwargs...) = isapprox(x * x', one(x); kwargs...)
_isunitary(x; kwargs...) = isunitary(x; kwargs...)
_isone(x; kwargs...) = isapprox(x, one(x); kwargs...)

"""
eval_show(x)

Use `show` to generate a string representation of `x`, then parse and evaluate the resulting expression.
"""
function eval_show(x)
str = sprint(show, x; context = (:module => @__MODULE__))
ex = Meta.parse(str)
return eval(ex)
end

include("testsuite/fusiontrees.jl")
include("testsuite/spaces.jl")
include("testsuite/tensors.jl")

end # module TensorKitTestSuite
4 changes: 4 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ testsuite = ParallelTestRunner.find_tests(@__DIR__)

# Exclude non-test files
delete!(testsuite, "setup") # shared setup module
delete!(testsuite, "TensorKitTestSuite") # reusable test suite module, not a test file itself
delete!(testsuite, "testsuite/fusiontrees") # only meant to be `include`d inside TensorKitTestSuite
delete!(testsuite, "testsuite/spaces")
delete!(testsuite, "testsuite/tensors")

# CUDA tests: only run if CUDA is functional
using CUDA: CUDA
Expand Down
97 changes: 9 additions & 88 deletions test/setup.jl
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,32 @@ module TestSetup

export randindextuple, randcircshift, _repartition, trivtuple
export default_tol
export smallset, randsector, hasfusiontensor, force_planar
export smallset, randsector, hasfusiontensor, force_planar, eval_show
export random_fusion
export sectorlist, fast_sectorlist
# export dim_isapprox
export default_spacelist, factorization_spacelist, ad_spacelist
export test_ad_rrule
export _isunitary, _isone
export TensorKitTestSuite

using Random
using Test: @test
using TensorKit
using TensorKit: ℙ, PlanarTrivial
using TensorKitSectors
using TensorOperations: IndexTuple, Index2Tuple
using Base.Iterators: take, product
using TupleTools
using MatrixAlgebraKit: MatrixAlgebraKit, diagview
using ChainRulesCore: NoTangent
using ChainRulesTestUtils: ChainRulesTestUtils, test_rrule
using Zygote: Zygote, rrule_via_ad

include(joinpath(@__DIR__, "TensorKitTestSuite.jl"))
using .TensorKitTestSuite
# not exported by TensorKitTestSuite to avoid clash with TensorKitSectors.SectorTestSuite
using .TensorKitTestSuite: _isunitary, _isone
using .TensorKitTestSuite: smallset, randsector, hasfusiontensor, random_fusion

Random.seed!(123456)

# IndexTuple utility
Expand Down Expand Up @@ -63,92 +68,8 @@ end
default_tol(::Type{<:Union{Float32, Complex{Float32}}}) = 1.0e-2
default_tol(::Type{<:Union{Float64, Complex{Float64}}}) = 1.0e-5

# Sector utility
# Sector lists
# --------------
smallset(::Type{I}) where {I <: Sector} = take(values(I), 5)
function smallset(::Type{ProductSector{Tuple{I1, I2}}}) where {I1, I2}
iter = product(smallset(I1), smallset(I2))
s = collect(i ⊠ j for (i, j) in iter if dim(i) * dim(j) <= 6)
return length(s) > 6 ? rand(s, 6) : s
end
function smallset(::Type{ProductSector{Tuple{I1, I2, I3}}}) where {I1, I2, I3}
iter = product(smallset(I1), smallset(I2), smallset(I3))
s = collect(i ⊠ j ⊠ k for (i, j, k) in iter if dim(i) * dim(j) * dim(k) <= 6)
return length(s) > 6 ? rand(s, 6) : s
end
function randsector(::Type{I}) where {I <: Sector}
s = collect(smallset(I))
a = rand(s)
while isunit(a) # don't use trivial label
a = rand(s)
end
return a
end
function hasfusiontensor(I::Type{<:Sector})
try
u = first(allunits(I))
fusiontensor(u, u, u)
return true
catch e
if e isa MethodError
return false
else
rethrow(e)
end
end
end

"""
force_planar(obj)

Replace an object with a planar equivalent -- i.e. one that disallows braiding.
"""
force_planar(V::ComplexSpace) = isdual(V) ? (ℙ^dim(V))' : ℙ^dim(V)
function force_planar(V::GradedSpace)
return GradedSpace((c ⊠ PlanarTrivial() => dim(V, c) for c in sectors(V))..., isdual(V))
end
force_planar(V::ProductSpace) = mapreduce(force_planar, ⊗, V)
function force_planar(tsrc::TensorMap{<:Any, ComplexSpace})
tdst = similar(tsrc, force_planar(codomain(tsrc)) ← force_planar(domain(tsrc)))
copyto!(block(tdst, PlanarTrivial()), block(tsrc, Trivial()))
return tdst
end
function force_planar(tsrc::TensorMap{<:Any, <:GradedSpace})
tdst = similar(tsrc, force_planar(codomain(tsrc)) ← force_planar(domain(tsrc)))
for (c, b) in blocks(tsrc)
copyto!(block(tdst, c ⊠ PlanarTrivial()), b)
end
return tdst
end

function random_fusion(I::Type{<:Sector}, ::Val{N}) where {N} # for fusion tree tests
N == 1 && return (randsector(I),)
tail = random_fusion(I, Val(N - 1))
s = randsector(I)
counter = 0
while isempty(⊗(s, first(tail))) && counter < 20
counter += 1
s = (counter < 20) ? randsector(I) : leftunit(first(tail))
end
return (s, tail...)
end

# # helper function to check that d - dim(c) < dim(V) <= d where c is the largest sector
# # to allow for truncations to have some margin with larger sectors
# function dim_isapprox(V::ElementarySpace, d::Int)
# dim_c_max = maximum(dim, sectors(V); init = 1)
# return max(0, d - dim_c_max) ≤ dim(V) ≤ d + dim_c_max
# end
# function dim_isapprox(V::ProductSpace, d::Int)
# dim_c_max = maximum(dim, blocksectors(V); init = 1)
# return max(0, d - dim_c_max) ≤ dim(V) ≤ d + dim_c_max
# end

_isunitary(x::Number; kwargs...) = isapprox(x * x', one(x); kwargs...)
_isunitary(x; kwargs...) = isunitary(x; kwargs...)
_isone(x; kwargs...) = isapprox(x, one(x); kwargs...)


uniquefusionsectorlist = (
Z2Irrep, Z3Irrep, Z4Irrep, Z3Irrep ⊠ Z4Irrep, U1Irrep,
FermionParity, FermionParity ⊠ FermionParity, FermionNumber, # fermionic
Expand Down
Loading
Loading