Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ authors:
given-names: "Jutho"
orcid: "https://orcid.org/0000-0002-0858-291X"
title: "TensorKit.jl"
version: "0.17.1"
version: "0.17.2"
doi: "10.5281/zenodo.8421339"
date-released: "2026-07-13"
date-released: "2026-09-20"
url: "https://github.com/QuantumKitHub/TensorKit.jl"
preferred-citation:
type: article
Expand Down
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "TensorKit"
uuid = "07d1fe3e-3e46-537d-9eac-e9e13d0d4cec"
version = "0.17.1"
version = "0.17.2"
authors = ["Jutho Haegeman, Lukas Devos"]

[workspace]
Expand Down
36 changes: 33 additions & 3 deletions docs/src/Changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,30 +18,60 @@ When making changes to this project, please update the "Unreleased" section with

When releasing a new version, move the "Unreleased" changes to a new version section with the release date.

## [Unreleased](https://github.com/QuantumKitHub/TensorKit.jl/compare/v0.17.1...HEAD)
## [Unreleased](https://github.com/QuantumKitHub/TensorKit.jl/compare/v0.17.2...HEAD)

### Added

### Changed

### Deprecated

### Removed

### Fixed

### Performance

## [0.17.2](https://github.com/QuantumKitHub/TensorKit.jl/compare/v0.17.1...v0.17.2) - 2026-09-20

### Added

- Precompilation workloads for common tensor construction, contraction, index manipulation, and factorization patterns, to reduce time-to-first-use ([#487](https://github.com/QuantumKitHub/TensorKit.jl/pull/487))
- TimerOutputs-based timers for internal kernels, and documentation for the global cache API, to aid profiling and debugging ([#525](https://github.com/QuantumKitHub/TensorKit.jl/pull/525), [#507](https://github.com/QuantumKitHub/TensorKit.jl/pull/507))
- Enzyme forward/reverse rules for `flip`, `insertleftunit`, `insertrightunit`, and `removeunit`, and for planar operations ([#488](https://github.com/QuantumKitHub/TensorKit.jl/pull/488), [#489](https://github.com/QuantumKitHub/TensorKit.jl/pull/489), [#474](https://github.com/QuantumKitHub/TensorKit.jl/pull/474))
- `planarcontract!` now supports an arbitrary (cyclic) output permutation `pAB`, in the same way as the non-planar `tensorcontract!`, along with the new helper `TensorKit.planar_contract_indices`, which `@planar` uses to emit canonical index tuples. ([#531](https://github.com/QuantumKitHub/TensorKit.jl/pull/531))

### Changed

- For sector types with `GenericUnit` such that colorings are not unique, `GradedSpace`, `ProductSpace` and `HomSpace` now check for this compatibility. In particular, this prevents the construction of `TensorMap`s with incompatible colorings, which previously either errored or produced empty tensors inconsistently. ([#515](https://github.com/QuantumKitHub/TensorKit.jl/pull/515))
- `TensorOperations.tensorcontract_structure` now requires the index tuples `pA` and `pB` to be planar (cyclic) partitions for sector types with `GenericUnit()`; hand-written planar kernels should canonicalize their index tuples with `TensorKit.planar_contract_indices` before allocating. ([#531](https://github.com/QuantumKitHub/TensorKit.jl/pull/531))

- The allocator is now threaded through `transpose!` in planar operations and through additional `@planar` call sites ([#502](https://github.com/QuantumKitHub/TensorKit.jl/pull/502), [#529](https://github.com/QuantumKitHub/TensorKit.jl/pull/529))
- Index manipulations use a single kernel operating on subblocks addressed by position: `StridedSubblocks` (sector-independent views into the flat data of a `TensorMap`) or `SubblockIterator` (any `AbstractTensorMap`, through `subblock`). The `TreeTransformer`s store the mapping between subblock positions and recoupling coefficients and are cached for every tensor type; conjugated and adjoint operands are handled through this mechanism instead of through `AdjointTensorMap` wrappers (internal) ([#516](https://github.com/QuantumKitHub/TensorKit.jl/issues/516))

### Deprecated

- The type alias `ZNSpace{N}` is deprecated in favour of `Vect[ZNIrrep{N}]` or `Rep[ℤ{N}]`: a type alias cannot compute the storage type from `N`, so the two only agree for small `N`. ([#511](https://github.com/QuantumKitHub/TensorKit.jl/pull/511))

### Removed

### Fixed

- Fixed bugs in the `GradedSpace` constructor and in `similar` for `SectorVector` ([#498](https://github.com/QuantumKitHub/TensorKit.jl/pull/498))
- `braid` for anyonic sectors no longer silently accepts duplicate `levels` ([#503](https://github.com/QuantumKitHub/TensorKit.jl/pull/503))
- Fixed backend and allocator insertion in the `@planar` macro ([#505](https://github.com/QuantumKitHub/TensorKit.jl/pull/505))
- `permute` and `transpose` ChainRules rules now support all keyword arguments ([#513](https://github.com/QuantumKitHub/TensorKit.jl/pull/513))
- Added a `hash` method for `FusionTreeBlock` to fix a caching bug affecting `permute` on `AdjointTensorMap`s ([#518](https://github.com/QuantumKitHub/TensorKit.jl/pull/518))
- `@planar` and hand-written planar kernels now canonicalize index partitions before allocating the contraction destination, fixing incorrect/erroring allocation for non-planar `pAB`, for `@planar` contractions whose leg-wise partitions are non-planar by themselves, and for contracting operands with different `spacetype`s (e.g. a `BlockTensorMap` with a `TensorMap`) ([#532](https://github.com/QuantumKitHub/TensorKit.jl/pull/532))
- `braid!`, `permute!` and `transpose!` with a `BraidingTensor` source now use the cached fusion tree transformers ([#516](https://github.com/QuantumKitHub/TensorKit.jl/issues/516))
- Made the ChainRules extension's rules consistent with the corresponding Mooncake and Enzyme rules ([#535](https://github.com/QuantumKitHub/TensorKit.jl/pull/535))
- `isunitspace`, `GradedSpace` `⊕`/`supremum`, `isconj(::ComplexSpace)`, `multi_associator`, `split`, `repartition`, the Mooncake `scalar` rule, `rand`/`randn`/`randexp`/`randisometry` with an explicit `rng`, `pinv(::DiagonalTensorMap)`, and `t1 / t2`: fixed various small bugs found during a pre-release audit ([#537](https://github.com/QuantumKitHub/TensorKit.jl/issues/537), [#538](https://github.com/QuantumKitHub/TensorKit.jl/issues/538), [#539](https://github.com/QuantumKitHub/TensorKit.jl/issues/539), [#540](https://github.com/QuantumKitHub/TensorKit.jl/issues/540), [#541](https://github.com/QuantumKitHub/TensorKit.jl/issues/541), [#542](https://github.com/QuantumKitHub/TensorKit.jl/issues/542), [#543](https://github.com/QuantumKitHub/TensorKit.jl/issues/543), [#544](https://github.com/QuantumKitHub/TensorKit.jl/issues/544), [#545](https://github.com/QuantumKitHub/TensorKit.jl/issues/545), [#546](https://github.com/QuantumKitHub/TensorKit.jl/issues/546))

### Performance
- `GradedSpace` operations (`dim`, `flip`, `⊕`, `⊖`, `fuse`, `infimum`, `supremum`, truncation) are now specialised on the storage type of the degeneracy dimensions, and tuple storage is used only for sector types with at most `TensorKit._NTUPLE_STORAGE_THRESHOLD` sectors so that sector types with many sectors no longer burden the compiler. ([#511](https://github.com/QuantumKitHub/TensorKit.jl/pull/511))

- `GradedSpace` operations (`dim`, `flip`, `⊕`, `⊖`, `fuse`, `infimum`, `supremum`, truncation) are now specialised on the storage type of the degeneracy dimensions, and tuple storage is used only for sector types with at most `TensorKit._NTUPLE_STORAGE_THRESHOLD` sectors so that sector types with many sectors no longer burden the compiler. ([#511](https://github.com/QuantumKitHub/TensorKit.jl/pull/511))
- Fixed a performance regression for non-abelian index manipulations, and reduced memory usage of `twist` ([#521](https://github.com/QuantumKitHub/TensorKit.jl/pull/521), [#501](https://github.com/QuantumKitHub/TensorKit.jl/pull/501))
- More error strings marked as lazy to reduce runtime overhead in non-error paths ([#492](https://github.com/QuantumKitHub/TensorKit.jl/pull/492))
- Use vector multiplication instead of an explicit loop for truncation error computation ([#443](https://github.com/QuantumKitHub/TensorKit.jl/pull/443))
- In-place `permute!`, `braid!` and `transpose!` with `AdjointTensorMap` sources or destinations, as well as `@tensor` expressions with `conj`, now use the same cached and sector-independent kernel as plain `TensorMap`s; other tensor types (e.g. `DiagonalTensorMap`) also use the cached fusion tree transformers, and `subblocks` iterates without repeating any space-level lookup ([#516](https://github.com/QuantumKitHub/TensorKit.jl/issues/516), [#519](https://github.com/QuantumKitHub/TensorKit.jl/pull/519), [#520](https://github.com/QuantumKitHub/TensorKit.jl/pull/520))

## [0.17.1](https://github.com/QuantumKitHub/TensorKit.jl/compare/v0.17.0...v0.17.1) - 2026-07-13
Expand Down
17 changes: 15 additions & 2 deletions ext/TensorKitMooncakeExt/tensoroperations.jl
Original file line number Diff line number Diff line change
Expand Up @@ -195,10 +195,23 @@ end
)
function Mooncake.rrule!!(::CoDual{typeof(TensorKit.scalar)}, t_dt::CoDual{<:AbstractTensorMap})
t, dt = arrayify(t_dt)
val = scalar(t)

# need to identify the correct unit for contributing the pullback
Bs = collect(blocks(t))
inds = findall(!iszero ∘ last, Bs)
c, val = if isempty(inds)
# for multifusion sectors, several unit blocks can be structurally present and
# simultaneously zero, by convention picking the first here.
# TODO: should we throw an error in this case?
first(first(Bs)), zero(scalartype(t))
else
c, b = Bs[only(inds)]
c, only(b)
end
function scalar_pullback(Δval)
first(blocks(dt))[2][1] = Δval
block(dt, c) .+= Δval
return NoRData(), NoRData()
end

return Mooncake.zero_fcodual(val), scalar_pullback
end
10 changes: 6 additions & 4 deletions src/fusiontrees/basic_manipulations.jl
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ true
0 <= M <= N ||
throw(ArgumentError("M should be between 0 and N = $N"))

innerlines_extended = (f.uncoupled[1], f.innerlines..., f.coupled)
innerlines_extended = N == 0 ? () : (f.uncoupled[1], f.innerlines..., f.coupled)
vertices_extended = (1, f.vertices...)

uncoupled₁ = ntuple(n -> f.uncoupled[n], M)
isdual₁ = ntuple(n -> f.isdual[n], M)
coupled₁ = M == 0 ? leftunit(f.uncoupled[1]) : innerlines_extended[M]
coupled₁ = M == 0 ? leftunit(f.coupled) : innerlines_extended[M]
innerlines₁ = ntuple(n -> f.innerlines[n], max(0, M - 2))
vertices₁ = ntuple(n -> f.vertices[n], max(0, M - 1))

Expand Down Expand Up @@ -138,8 +138,10 @@ function multi_associator(long::FusionTree{I, N}, short) where {I, N}
length(short) == N - 1 ||
throw(DimensionMismatch("second fusion tree must have one less uncoupled leg"))
uncoupled = long.uncoupled
(uncoupled[2:end] == short.uncoupled && long.isdual[2:end] == short.isdual) ||
return zero(sectorscalartype(typeof(long.coupled)))
if !(uncoupled[2:end] == short.uncoupled && long.isdual[2:end] == short.isdual)
return FusionStyle(I) isa MultiplicityFreeFusion ?
zero(sectorscalartype(I)) : zeros(sectorscalartype(I), 1)
end

if FusionStyle(I) isa MultiplicityFreeFusion
coeff = one(sectorscalartype(I))
Expand Down
2 changes: 1 addition & 1 deletion src/fusiontrees/duality_manipulations.jl
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ function _repartition_body(N)
return src => one(T)
else
U = copyto!(zeros(T, length(src), length(src)), LinearAlgebra.I)
return src, U
return src => U
end
end
else
Expand Down
16 changes: 13 additions & 3 deletions src/planar/planaroperations.jl
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,20 @@ function planar_contract_indices(
oindA′, cindA′ = _planar_rotate(indA, oindA, cindA)
cindB′, oindB′ = _planar_rotate(indB, cindB, oindB)

# if all indices are contracted, fix the residual rotation using the other tensor
if isempty(oindA′) && !isempty(cindA)
# an empty arc leaves a residual rotation, which has to be fixed elsewhere
if isempty(cindA) && isempty(cindB)
Comment thread
lkdvos marked this conversation as resolved.
# nothing is contracted: both rotations are fixed by the cycle of the destination
NA = length(oindA)
indAB = (pAB[1]..., reverse(pAB[2])...)
posA′, posB′ = _planar_rotate(
indAB, ntuple(identity, Val(NA)), ntuple(n -> NA + n, Val(length(oindB)))
)
oindA′ = TupleTools.getindices(oindA, posA′)
oindB′ = map(n -> oindB[n - NA], posB′)
elseif isempty(oindA′)
# everything is contracted: the rotation is fixed by the other tensor
cindA′ = _rotate_to(cindA′, cindA[something(findfirst(==(first(cindB′)), cindB))])
elseif isempty(oindB′) && !isempty(cindB)
elseif isempty(oindB′)
cindB′ = _rotate_to(cindB′, cindB[something(findfirst(==(first(cindA′)), cindA))])
end
TupleTools.sort(tuple.(cindA′, cindB′)) == TupleTools.sort(tuple.(cindA, cindB)) ||
Expand Down
2 changes: 1 addition & 1 deletion src/spaces/complexspace.jl
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Base.axes(V::ComplexSpace, ::Trivial = Trivial()) = Base.OneTo(dim(V))
dual(V::ComplexSpace) = ComplexSpace(dim(V), !isdual(V))
Base.conj(V::ComplexSpace) = dual(V)
isdual(V::ComplexSpace) = V.dual
isconj(V::ComplexSpace) = true
isconj(V::ComplexSpace) = isdual(V)
Comment thread
lkdvos marked this conversation as resolved.
flip(V::ComplexSpace) = dual(V)

unitspace(::Type{ComplexSpace}) = ComplexSpace(1)
Expand Down
10 changes: 8 additions & 2 deletions src/spaces/gradedspace.jl
Original file line number Diff line number Diff line change
Expand Up @@ -162,13 +162,16 @@ zerospace(S::Type{<:GradedSpace}) = S()
function ⊕(V₁::DictGradedSpace{I}, V₂::DictGradedSpace{I}) where {I <: Sector}
dual1 = isdual(V₁)
dual1 == isdual(V₂) || throw(SpaceMismatch("Direct sum of a vector space and a dual space does not exist"))
return typeof(V₁)(mergewith(+, V₁.dims, V₂.dims), dual1)
newdims = mergewith(+, V₁.dims, V₂.dims)
_check_unit_homogeneity(I, keys(newdims))
return typeof(V₁)(newdims, dual1)
end
function ⊕(V₁::TupleGradedSpace{I}, V₂::TupleGradedSpace{I}) where {I <: Sector}
dual1 = isdual(V₁)
dual1 == isdual(V₂) ||
throw(SpaceMismatch("Direct sum of a vector space and a dual space does not exist"))
newdims = map(+, V₁.dims, V₂.dims)
_check_unit_homogeneity(I, (values(I)[n] for n in eachindex(newdims) if !iszero(newdims[n])))
return typeof(V₁)(newdims, dual1)
end
@noinline _throw_not_subspace(V, W) = throw(SpaceMismatch(lazy"$(W) is not a subspace of $(V)"))
Expand Down Expand Up @@ -248,12 +251,15 @@ function supremum(V₁::TupleGradedSpace{I}, V₂::TupleGradedSpace{I}) where {I
Visdual = isdual(V₁)
Visdual == isdual(V₂) || throw(SpaceMismatch("Supremum of space and dual space does not exist"))
newdims = map(max, V₁.dims, V₂.dims)
_check_unit_homogeneity(I, (values(I)[n] for n in eachindex(newdims) if !iszero(newdims[n])))
return typeof(V₁)(newdims, Visdual)
end
function supremum(V₁::DictGradedSpace{I}, V₂::DictGradedSpace{I}) where {I <: Sector}
Visdual = isdual(V₁)
Visdual == isdual(V₂) || throw(SpaceMismatch("Supremum of space and dual space does not exist"))
return typeof(V₁)(mergewith(max, V₁.dims, V₂.dims), Visdual)
newdims = mergewith(max, V₁.dims, V₂.dims)
_check_unit_homogeneity(I, keys(newdims))
return typeof(V₁)(newdims, Visdual)
end

hassector(V::GradedSpace{I}, s::I) where {I <: Sector} = dim(V, s) != 0
Expand Down
2 changes: 1 addition & 1 deletion src/spaces/vectorspaces.jl
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ function isunitspace(V::ElementarySpace)
return if isa(UnitStyle(I), SimpleUnit)
isisomorphic(V, unitspace(V))
else
(dim(V) == 0 || !all(isunit, sectors(V))) && return false
(dim(V) == 1 && all(isunit, sectors(V))) || return false
return true
end
end
Expand Down
8 changes: 2 additions & 6 deletions src/tensors/diagonal.jl
Original file line number Diff line number Diff line change
Expand Up @@ -366,12 +366,8 @@ end
function LinearAlgebra.pinv(d::DiagonalTensorMap; kwargs...)
T = eltype(d.data)
atol = get(kwargs, :atol, zero(real(T)))
if iszero(atol)
rtol = get(kwargs, :rtol, zero(real(T)))
else
rtol = sqrt(eps(real(float(one(T))))) * length(d.data)
end
pdata = let tol = max(atol, rtol * maximum(abs, d.data))
rtol = get(kwargs, :rtol, atol > 0 ? zero(real(T)) : _default_rtol(d))
pdata = let tol = max(atol, rtol * maximum(abs, d.data; init = zero(real(T))))
map(x -> abs(x) < tol ? zero(x) : pinv(x), d.data)
end
return DiagonalTensorMap(pdata, d.domain)
Expand Down
2 changes: 1 addition & 1 deletion src/tensors/linalg.jl
Original file line number Diff line number Diff line change
Expand Up @@ -417,7 +417,7 @@ end
function Base.:(/)(t1::AbstractTensorMap, t2::AbstractTensorMap)
domain(t1) == domain(t2) ||
throw(SpaceMismatch("non-matching domains in t1 / t2"))
T = promote_type(scalartype(t1), scalartype(t2))
T = float(promote_type(scalartype(t1), scalartype(t2)))
t = similar(t1, T, codomain(t1) ← codomain(t2))
for (c, b) in blocks(t)
copy!(b, block(t1, c) / block(t2, c))
Expand Down
2 changes: 1 addition & 1 deletion src/tensors/tensor.jl
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ for randf in (:rand, :randn, :randexp, :randisometry)
function $randfun(
rng::Random.AbstractRNG, ::Type{TorA}, codomain::TensorSpace
) where {TorA}
return $randfun(rng, TorA, codomain ← one(domain))
return $randfun(rng, TorA, codomain ← one(codomain))
end

# filling in default eltype
Expand Down
14 changes: 14 additions & 0 deletions test/symmetries/singletree.jl
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ using TensorKitSectors
f′ = @constinferred TK.join(f₁, f₂)
@test f′ == f
end

f0 = FusionTree{I}((), TK.leftunit(coupled), (), (), ())
f0₁, f0₂ = @constinferred TK.split(f0, 0)
@test f0₁ == f0
@test TK.join(f0₁, f0₂) == f0
end

@testset "Fusion tree: multi_Fmove" begin
Expand Down Expand Up @@ -131,6 +136,15 @@ using TensorKitSectors
end
end
@test norm(values(d)) < 1.0e-12

short_mismatch = FusionTree{I}(
f′s[1].uncoupled, f′s[1].coupled, map(!, f′s[1].isdual),
f′s[1].innerlines, f′s[1].vertices
)
if short_mismatch.isdual != f.isdual[2:end]
coeff_mismatch = @constinferred TK.multi_associator(f, short_mismatch)
@test all(iszero, coeff_mismatch)
end
end
end

Expand Down
20 changes: 20 additions & 0 deletions test/tensors/planar.jl
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ end
pAB3 = ((1, 2), (3, 4, 5))
E′ = force_planar(randn(ℂ^2 ⊗ (ℂ^2)' ← ℂ^5 ⊗ (ℂ^2)' ⊗ ℂ^4))
@test_throws ArgumentError planarcontract!(E′, A′, pA, B′, pB, pAB3, true, true)

# without contracted indices the cyclic reordering is fixed by `pAB` alone
F′ = force_planar(randn(ℂ^2 ⊗ ℂ^3 ← ℂ^4))
G′ = force_planar(randn(ℂ^5 ← ℂ^6 ⊗ ℂ^7))
H′ = force_planar(randn(ℂ^2 ⊗ ℂ^3 ⊗ ℂ^5 ← ℂ^4 ⊗ ℂ^6 ⊗ ℂ^7))
pF, pG = ((1, 2, 3), ()), ((), (1, 2, 3))
pFG = ((1, 2, 4), (3, 5, 6))
@test planarcontract!(H′, F′, pF, G′, pG, pFG, true, false) ≈ F′ ⊗ G′
end

@testset "planar_contract_indices" begin
Expand Down Expand Up @@ -154,6 +162,18 @@ end
@test pAB2′ == ((1, 2, 3), (4, 5))
@test last(planar_contract_indices(WA, pA2, WB, pB2, ((2, 1), (3, 4, 5)))) ==
((2, 3), (1, 4, 5))

# no indices contracted: the rotations are only fixed by the destination
WAo, WBo = ℂ^2 ⊗ ℂ^3 ← ℂ^4, ℂ^5 ← ℂ^6
pAo, pBo = ((1, 2, 3), ()), ((), (1, 2))
pAo′, pBo′, pABo′ = @constinferred planar_contract_indices(
WAo, pAo, WBo, pBo, ((1, 2, 4), (3, 5))
)
@test (pAo′, pBo′) == (((3, 1, 2), ()), ((), (2, 1)))
@test transpose(WAo, pAo′) isa TensorKit.HomSpace
@test transpose(WBo, pBo′) isa TensorKit.HomSpace
@test TensorOperations.tensorcontract(WAo, pAo′, false, WBo, pBo′, false, pABo′) ==
(ℂ^2 ⊗ ℂ^3 ⊗ ℂ^5 ← ℂ^4 ⊗ ℂ^6)
end
end

Expand Down
Loading