From 12bf1222f26228d2bdf1fc7405386389e255c33f Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Wed, 8 Jul 2026 14:34:33 -0400 Subject: [PATCH 01/15] =?UTF-8?q?Add=20index-tuple=20constructor=20error,?= =?UTF-8?q?=20index-keyed=20replacedimnames,=20svd=5Ftrunc=20=CF=B5,=20tri?= =?UTF-8?q?vialrange=20for=20named=20ranges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bugfixes and additive index utilities. `ITensor(array, Index-tuple)` now throws with guidance toward `array[i, j]` and `ITensor(array, name.((i, j)))` instead of silently mis-constructing. `replacedimnames` accepts `Index` keys by stripping to the dimname. `svd_trunc` returns the truncation error `ϵ` as a fourth output. And `trivialrange(::NamedUnitRange)` mints a fresh trivial named range on the same backend. --- Project.toml | 6 +++++- docs/Project.toml | 2 +- examples/Project.toml | 2 +- src/abstractnamedtensor.jl | 6 ++++++ src/namedtensor.jl | 20 ++++++++++++------ src/namedunitrange.jl | 11 +++++++++- src/tensoralgebra.jl | 37 +++++++++++++++++++++++++++++++- test/Project.toml | 4 ++-- test/test_basics.jl | 16 ++++++++++---- test/test_tensoralgebra.jl | 43 +++++++++++++++++++++++++++++++++----- 10 files changed, 125 insertions(+), 22 deletions(-) diff --git a/Project.toml b/Project.toml index 0d15309..4b40170 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "ITensorBase" uuid = "4795dd04-0d67-49bb-8f44-b89c448a1dc7" -version = "0.10.9" +version = "0.11.0" authors = ["ITensor developers and contributors"] [workspace] @@ -32,6 +32,10 @@ OMEinsumContractionOrders = "6f22d1fd-8eed-4bb7-9776-e7d684900715" TensorKit = "07d1fe3e-3e46-537d-9eac-e9e13d0d4cec" TensorOperations = "6aa20fa7-93e2-5fca-9bc0-fbd0db3c71a2" +[sources.TensorAlgebra] +rev = "mf/nextgen-followups-round1" +url = "https://github.com/ITensor/TensorAlgebra.jl" + [extensions] ITensorBaseAdaptExt = "Adapt" ITensorBaseMooncakeExt = "Mooncake" diff --git a/docs/Project.toml b/docs/Project.toml index f52464c..d54500c 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -12,7 +12,7 @@ path = ".." [compat] Documenter = "1" -ITensorBase = "0.10" +ITensorBase = "0.11" ITensorFormatter = "0.2.27" Literate = "2" MatrixAlgebraKit = "0.2, 0.3, 0.4, 0.5, 0.6" diff --git a/examples/Project.toml b/examples/Project.toml index c1a24b5..5af97d2 100644 --- a/examples/Project.toml +++ b/examples/Project.toml @@ -6,5 +6,5 @@ MatrixAlgebraKit = "6c742aac-3347-4629-af66-fc926824e5e4" path = ".." [compat] -ITensorBase = "0.10" +ITensorBase = "0.11" MatrixAlgebraKit = "0.2, 0.3, 0.4, 0.5, 0.6" diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index 7e01bc9..11678d5 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -482,7 +482,13 @@ julia> dimnames(replacedimnames(a, :i => :k)) See also [`dimnames`](@ref). """ function replacedimnames end +# Strip an `Index`/`NamedUnitRange` down to its dimension name so an index-keyed pair +# (`i => j`) relabels like the name-keyed pair (`name(i) => name(j)`); `dimnames(a)` holds +# names, so a raw-index key would never match and silently no-op. +to_dimname(x) = x +to_dimname(x::NamedUnitRange) = name(x) function replacedimnames(a::AbstractNamedTensor, replacements::Pair...) + replacements = map(p -> to_dimname(first(p)) => to_dimname(last(p)), replacements) new_dimnames = replace(dimnames(a), replacements...) return nameddims(unnamed(a), new_dimnames) end diff --git a/src/namedtensor.jl b/src/namedtensor.jl index c51c3d1..100f59c 100644 --- a/src/namedtensor.jl +++ b/src/namedtensor.jl @@ -26,15 +26,23 @@ struct NamedTensor{DimName} <: AbstractNamedTensor{DimName} unnamed::Any dimnames::Vector{DimName} function NamedTensor{DimName}(unnamed, dimnames) where {DimName} - dimnames = collect(DimName, dimnames) - # Catch the common ITensors.jl-style mistake of passing indices as the names. - any(dimname -> dimname isa NamedUnitRange, dimnames) && throw( + # Catch the common ITensors.jl-style mistake of passing indices (`NamedUnitRange`s + # such as `Index`) as the names. Checked before `collect(DimName, ...)`, which would + # otherwise fail with an opaque `convert` error when `DimName` is the dimension-name + # type (e.g. `ITensor`'s `IndexName`). + ( + dimnames isa NamedUnitRange || + any(dimname -> dimname isa NamedUnitRange, dimnames) + ) && throw( ArgumentError( - "The `NamedTensor` constructor takes dimension names only, not indices \ - (`NamedUnitRange`s), got $(dimnames). To build a `NamedTensor` from an \ - array and indices, index the array instead, as in `array[i, j]`." + "The `NamedTensor`/`ITensor` constructor takes dimension names, not indices \ + (`NamedUnitRange`s such as `Index`), got $(dimnames). To build a tensor from \ + an array and indices, either index the array to inherit the space from the \ + indices, as in `array[i, j]`, or attach only the names and take the space \ + from the array, as in `ITensor(array, name.((i, j)))`." ) ) + dimnames = collect(DimName, dimnames) TensorAlgebra.ndims(unnamed) == length(dimnames) || throw(ArgumentError("Number of named dims must match ndims.")) allunique(dimnames) || diff --git a/src/namedunitrange.jl b/src/namedunitrange.jl index 7e33d5b..b2dfca4 100644 --- a/src/namedunitrange.jl +++ b/src/namedunitrange.jl @@ -1,4 +1,4 @@ -using TensorAlgebra: to_range +using TensorAlgebra: TensorAlgebra, to_range, trivialrange """ NamedUnitRange{Name} @@ -64,6 +64,15 @@ end # This can be customized to output different named unit range types. namedunitrange(r::AbstractUnitRange, name) = NamedUnitRange(r, name) +# Mint a fresh trivial *named* range matching `r`'s backend: the trivial range of the +# underlying (unnamed) axis, carrying a fresh unique name of `r`'s name type. +function TensorAlgebra.trivialrange(r::NamedUnitRange{Name}) where {Name} + return namedunitrange(trivialrange(unnamed(r)), uniquename(Name)) +end +function TensorAlgebra.trivialrange(r::NamedUnitRange{Name}, n::Integer) where {Name} + return namedunitrange(trivialrange(unnamed(r), n), uniquename(Name)) +end + # Shorthand: attach an existing name to a range. named(r::AbstractUnitRange, name) = namedunitrange(r, name) diff --git a/src/tensoralgebra.jl b/src/tensoralgebra.jl index bcc239a..dad4190 100644 --- a/src/tensoralgebra.jl +++ b/src/tensoralgebra.jl @@ -159,7 +159,7 @@ end # SVD (three-output). # -for f in [:svd_compact, :svd_full, :svd_trunc] +for f in [:svd_compact, :svd_full] f_nameddims = Symbol(f, "_nameddims") @eval begin function MAK.$f( @@ -199,6 +199,41 @@ for f in [:svd_compact, :svd_full, :svd_trunc] end end +# `svd_trunc` mirrors the three-output SVD above but also returns the truncation error `ϵ` +# (the 2-norm of the discarded singular values), matching MatrixAlgebraKit's four-output +# `svd_trunc`, so it is spelled out here rather than sharing the loop. +function MAK.svd_trunc( + a::AbstractNamedTensor, dimnames_codomain, dimnames_domain; kwargs... + ) + return svd_trunc_nameddims(a, dimnames_codomain, dimnames_domain; kwargs...) +end +function svd_trunc_nameddims( + a::AbstractNamedTensor, dimnames_codomain, dimnames_domain; kwargs... + ) + codomain = name.(dimnames_codomain) + domain = name.(dimnames_domain) + u_unnamed, s_unnamed, v_unnamed, ϵ = TA.svd_trunc( + unnamed(a), dimnames(a), codomain, domain; kwargs... + ) + name_u = uniquename(dimnametype(a)) + name_v = uniquename(dimnametype(a)) + u = nameddims(u_unnamed, (codomain..., name_u)) + s = nameddims(s_unnamed, (name_u, name_v)) + v = nameddims(v_unnamed, (name_v, domain...)) + return u, s, v, ϵ +end +function MAK.svd_trunc(a::AbstractNamedTensor, dimnames_codomain; kwargs...) + return svd_trunc_nameddims(a, dimnames_codomain; kwargs...) +end +function svd_trunc_nameddims(a::AbstractNamedTensor, dimnames_codomain; kwargs...) + return MAK.svd_trunc( + a, + dimnames_codomain, + dimnames_setdiff(dimnames(a), name.(dimnames_codomain)); + kwargs... + ) +end + # # Singular values. # diff --git a/test/Project.toml b/test/Project.toml index f8ac67a..6c6d059 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -24,14 +24,14 @@ VectorInterface = "409d34a3-91d5-4945-b6ec-7529ddf182d8" WrappedUnions = "325db55a-9c6c-5b90-b1a2-ec87e7a38c44" [sources.ITensorBase] -path = ".." +path = "/Users/mfishman/.julia/dev/ITensorBase/.worktrees/mf/nextgen-followups-round1" [compat] AbstractTrees = "0.4.5" Adapt = "4" Aqua = "0.8.9" Combinatorics = "1" -ITensorBase = "0.10" +ITensorBase = "0.11" ITensorPkgSkeleton = "0.3.42" JLArrays = "0.2, 0.3" LinearAlgebra = "1.10" diff --git a/test/test_basics.jl b/test/test_basics.jl index 748d3ee..d609c85 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -1,6 +1,6 @@ -using ITensorBase: ITensorBase, Index, IndexName, NamedTensor, dimnametype, gettag, hastag, - id, inds, mapinds, name, named, plev, prime, setplev, settag, tags, uniquename, unname, - unnamed, unsettag +using ITensorBase: ITensorBase, ITensor, Index, IndexName, NamedTensor, dimnametype, gettag, + hastag, id, inds, mapinds, name, named, plev, prime, setplev, settag, tags, uniquename, + unname, unnamed, unsettag using Test: @test, @test_broken, @test_throws, @testset using UUIDs: UUID @@ -123,8 +123,16 @@ using UUIDs: UUID @test_throws MethodError NamedTensor(randn(elt, 2, 2), :i, :j) # The constructor takes names only, not indices, so passing indices (the - # ITensors.jl idiom) errors. + # ITensors.jl idiom) errors, whether as a tuple, a vector, or a single index. + i, j = Index.((2, 3)) @test_throws ArgumentError NamedTensor(randn(elt, 2, 2), Index.((2, 2))) + @test_throws ArgumentError ITensor(randn(elt, 2, 3), (i, j)) + @test_throws ArgumentError ITensor(randn(elt, 2, 3), [i, j]) + @test_throws ArgumentError ITensor(randn(elt, 2), i) + # The two supported constructions: index the array (inherit the space from the + # indices), or attach only the names (take the space from the array). + @test randn(elt, 2, 3)[i, j] isa ITensor + @test ITensor(randn(elt, 2, 3), name.((i, j))) isa ITensor i, j = Index.((3, 4)) a = randn(elt, i, j) diff --git a/test/test_tensoralgebra.jl b/test/test_tensoralgebra.jl index 5a8f258..bdd20ea 100644 --- a/test/test_tensoralgebra.jl +++ b/test/test_tensoralgebra.jl @@ -2,11 +2,11 @@ using ITensorBase: ITensorBase, Index, dimnames, inds, name, namedoneto, prime, replacedimnames, uniquename, unname, unnamed using LinearAlgebra: LinearAlgebra, norm using MatrixAlgebraKit: left_null, left_orth, left_polar, lq_compact, lq_full, qr_compact, - qr_full, right_null, right_orth, right_polar, svd_compact, svd_trunc + qr_full, right_null, right_orth, right_polar, svd_compact, svd_trunc, svd_vals using StableRNGs: StableRNG using TensorAlgebra.MatrixAlgebra: gram_eigh_full, gram_eigh_full_with_pinv -using TensorAlgebra: - TensorAlgebra, contract, matricize, project, unchecked_project, unmatricize +using TensorAlgebra: TensorAlgebra, contract, matricize, project, trivialrange, + unchecked_project, unmatricize using Test: @test, @test_broken, @testset @testset "TensorAlgebra (eltype=$(elt))" for elt in @@ -106,11 +106,19 @@ using Test: @test, @test_broken, @testset u, s, v = svd_compact(a, (i, k), (j, l)) @test u * s * v ≈ a - # Test truncation. + # Test truncation. `svd_trunc` returns a fourth output `ϵ`, the truncation error + # (2-norm of the discarded singular values), matching MatrixAlgebraKit. a = randn(elt, i, j, k, l) - u, s, v = svd_trunc(a, (i, k), (j, l); trunc = (; maxrank = 2)) + res = svd_trunc(a, (i, k), (j, l); trunc = (; maxrank = 2)) + @test length(res) == 4 + u, s, v, ϵ = res @test u * s * v ≉ a @test size(s) == (2, 2) + @test ϵ isa Real + @test ϵ ≥ 0 + # `ϵ` equals the 2-norm of the discarded singular values. + vals = svd_vals(a, (i, k), (j, l)) + @test ϵ ≈ norm(sort(vals; rev = true)[3:end]) end @testset "left_null/right_null" begin dims = (2, 2, 2, 2) @@ -177,4 +185,29 @@ using Test: @test, @test_broken, @testset @test dimnames(bra) == [name(i)] @test unname(bra, (i,)) == v end + @testset "replacedimnames with index keys" begin + i, j, k = namedoneto.((2, 3, 2), ("i", "j", "k")) + a = randn(elt, i, j) + # An `Index`-keyed pair relabels like the name-keyed pair rather than silently + # no-opping, and the result stays an `ITensor` (not `NamedTensor{Any}`). + @test dimnames(replacedimnames(a, i => k)) == + dimnames(replacedimnames(a, "i" => "k")) + @test replacedimnames(a, i => k) isa typeof(a) + # Mixed index/name keys and values are accepted. + @test dimnames(replacedimnames(a, i => "k")) == + dimnames(replacedimnames(a, "i" => "k")) + @test dimnames(replacedimnames(a, "i" => k)) == + dimnames(replacedimnames(a, "i" => "k")) + end + @testset "trivialrange on named ranges" begin + i = Index(3) + r = trivialrange(i) + @test r isa Index + @test length(r) == 1 + @test name(r) != name(i) + rn = trivialrange(i, 4) + @test rn isa Index + @test length(rn) == 4 + @test name(rn) != name(i) + end end From 145eab1ca14c58d660e8c848bd42431589f68a29 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Wed, 8 Jul 2026 14:51:53 -0400 Subject: [PATCH 02/15] Add whole-tensor index ops (mapinds, prime, noprime, sim), name-only replaceinds, rank-0 similar/one Whole-tensor index manipulation on `AbstractNamedTensor`. `mapinds(f, t)` relabels every index name-only (leaving the space untouched), with `prime(t)`, `noprime(t)`, and `sim(t)` built on it. `replaceinds` becomes a name-only relabel, a synonym for `replacedimnames`, and `sim(i)` mints a fresh id preserving tags and prime level. `similar(a, ())` and `one(a)` give a rank-0 scalar tensor on `a`'s backend. --- src/ITensorBase.jl | 4 +-- src/abstractnamedtensor.jl | 53 ++++++++++++++++++++++++++------ src/index.jl | 41 +++++++++++++++++++++++-- test/test_basics.jl | 62 ++++++++++++++++++++++++++++++++++++-- test/test_exports.jl | 4 +-- 5 files changed, 145 insertions(+), 19 deletions(-) diff --git a/src/ITensorBase.jl b/src/ITensorBase.jl index 72cb9b7..4d19eae 100644 --- a/src/ITensorBase.jl +++ b/src/ITensorBase.jl @@ -2,8 +2,8 @@ module ITensorBase export AbstractNamedTensor, NamedTensor, AbstractITensor, ITensor, Index, NamedUnitRange, aligndims, aligneddims, apply, codomainnames, dimnames, - dimnametype, domainnames, inds, named, nameddims, noprime, operator, prime, - similar_operator, state, uniquename + dimnametype, domainnames, inds, mapinds, named, nameddims, noprime, operator, + prime, replaceinds, sim, similar_operator, state, uniquename using Compat: @compat @compat public @names @compat public IndexName, name, nametype, replacedimnames, setname, unnamed, unnamedtype diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index 11678d5..1a42ac7 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -454,6 +454,24 @@ function Base.similar( ) return similar_nameddims(a, elt, inds) end + +# Rank-0 (empty named axes): a scalar tensor on `a`'s backend, e.g. a backend-matched unit +# for a product accumulator. Spelled out separately because the tuple forms above require at +# least one `NamedUnitRange`. +Base.similar(a::AbstractNamedTensor, inds::Tuple{}) = similar(a, eltype(a), inds) +function Base.similar(a::AbstractNamedTensor, elt::Type, inds::Tuple{}) + return similar_nameddims(a, elt, inds) +end + +""" + one(a::AbstractNamedTensor) -> AbstractNamedTensor + +Return a rank-0 (scalar) tensor holding `one(scalartype(a))` on `a`'s backend. This is the +multiplicative unit matching `a`'s element type and backend (dense, graded, `TensorMap`, …), +useful as the seed of a product accumulator. +""" +Base.one(a::AbstractNamedTensor) = fill!(similar(a, ()), one(scalartype(a))) + function setdimnames(a::AbstractNamedTensor, dimnames) return nameddims(unnamed(a), dimnames) end @@ -498,18 +516,33 @@ function replacedimnames(f, a::AbstractNamedTensor) end mapdimnames(f, a::AbstractNamedTensor) = replacedimnames(f, a) -# Replace over `axes` (a `Tuple`) rather than `inds` (a `Vector`): `replace` on a `Vector` -# is homogeneous and would fail to convert a replacement index backed by a different range -# type (e.g. `UnitRange` into a `OneTo`-backed vector), whereas a `Tuple` admits the mixed -# element types. The result is splatted into `getindex`, so only the order matters. +""" + replaceinds(a::AbstractNamedTensor, replacements::Pair...) + replaceinds(f, a::AbstractNamedTensor) + +Return a tensor with the same data as `a` but with its indices relabeled, a backend-agnostic +synonym for [`replacedimnames`](@ref). This is a name-only relabel: it rewrites the index +names and leaves the underlying space untouched (replacing the space instead would +scalar-index a graded axis). The first form takes `old => new` pairs, relabeling matching +indices and leaving the rest unchanged; the second replaces each index with `f(index)`. + +See also [`mapinds`](@ref), [`replacedimnames`](@ref). +""" function replaceinds(a::AbstractNamedTensor, replacements::Pair...) - new_inds = replace(axes(a), replacements...) - return unnamed(a)[new_inds...] -end -function replaceinds(f, a::AbstractNamedTensor) - new_inds = replace(f, axes(a)) - return unnamed(a)[new_inds...] + return replacedimnames(a, replacements...) end +replaceinds(f, a::AbstractNamedTensor) = replacedimnames(f, a) + +""" + mapinds(f, a::AbstractNamedTensor) + +Return a tensor with the same data as `a` but with each of its indices replaced by +`f(index)`, a name-only relabel that leaves the underlying space untouched. This is the +whole-tensor index-map primitive behind [`prime`](@ref), [`noprime`](@ref), and +[`sim`](@ref). + +See also [`replaceinds`](@ref). +""" mapinds(f, a::AbstractNamedTensor) = replaceinds(f, a) # `Base.isempty(a::AbstractArray)` is defined as `length(a) == 0`, diff --git a/src/index.jl b/src/index.jl index 4f31493..895be4a 100644 --- a/src/index.jl +++ b/src/index.jl @@ -125,11 +125,13 @@ end """ prime(i) + prime(t::AbstractNamedTensor) Increment the prime level of an index or index name by one, returning a new index that is distinct from `i`. Priming is the usual way to make a second copy of an index that carries the same tags but is not contracted against the original. The inverse is -[`noprime`](@ref), which resets the prime level to zero. +[`noprime`](@ref), which resets the prime level to zero. Given a tensor, prime all of its +indices. # Examples @@ -149,9 +151,11 @@ function prime end """ noprime(i) + noprime(t::AbstractNamedTensor) Reset the prime level of an index or index name to zero, returning a new index. This -undoes any number of [`prime`](@ref) calls. +undoes any number of [`prime`](@ref) calls. Given a tensor, reset the prime level of all of +its indices. # Examples @@ -166,8 +170,34 @@ See also [`prime`](@ref), [`Index`](@ref). """ function noprime end +""" + sim(i) + sim(t::AbstractNamedTensor) + +Return a "similar" index: a new index (or, given a tensor, a tensor with all of its indices +replaced) carrying the same tags and prime level as `i` but a fresh unique identifier, so it +is distinct from `i` and will not contract against it. This is the index-manipulation +spelling of [`uniquename`](@ref) on an index. + +# Examples + +```jldoctest +julia> i = Index(2); + +julia> sim(i) == i +false + +julia> length(sim(i)) +2 +``` + +See also [`uniquename`](@ref), [`prime`](@ref). +""" +function sim end + prime(n::IndexName) = setplev(n, plev(n) + 1) noprime(n::IndexName) = setplev(n, 0) +sim(n::IndexName) = uniquename(n) # Show a short prefix of the `UUID` id rather than the full 36-character string, # enough to disambiguate indices at a glance without dominating the output. A @@ -250,6 +280,13 @@ unsettag(i::Index, tagname) = setname(i, unsettag(name(i), tagname)) setplev(i::Index, plev) = setname(i, setplev(name(i), plev)) prime(i::Index) = setname(i, prime(name(i))) noprime(i::Index) = setname(i, noprime(name(i))) +sim(i::Index) = setname(i, sim(name(i))) + +# Whole-tensor index manipulation: relabel every index name-only via `mapinds`, leaving the +# data and spaces untouched. +prime(a::AbstractNamedTensor) = mapinds(prime, a) +noprime(a::AbstractNamedTensor) = mapinds(noprime, a) +sim(a::AbstractNamedTensor) = mapinds(sim, a) function primestring(plev) if plev < 0 diff --git a/test/test_basics.jl b/test/test_basics.jl index d609c85..ca6c45c 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -1,6 +1,6 @@ -using ITensorBase: ITensorBase, ITensor, Index, IndexName, NamedTensor, dimnametype, gettag, - hastag, id, inds, mapinds, name, named, plev, prime, setplev, settag, tags, uniquename, - unname, unnamed, unsettag +using ITensorBase: ITensorBase, AbstractNamedTensor, ITensor, Index, IndexName, NamedTensor, + dimnametype, gettag, hastag, id, inds, mapinds, name, named, noprime, plev, prime, + replaceinds, setplev, settag, sim, tags, uniquename, unname, unnamed, unsettag using Test: @test, @test_broken, @test_throws, @testset using UUIDs: UUID @@ -176,4 +176,60 @@ using UUIDs: UUID @test sprint(show, "text/plain", i) == "Index(length=2|id=$(first(string(id(i)), 8))|X=>Y)" end + @testset "whole-tensor index manipulation" begin + elt = Float64 + i, j = Index.((2, 3)) + a = randn(elt, i, j) + + # `prime`/`noprime` relabel every index name-only, leaving the data untouched. + a′ = prime(a) + @test unnamed(a′) == unnamed(a) + @test issetequal(inds(a′), (prime(i), prime(j))) + @test noprime(a′) == a + @test issetequal(inds(noprime(prime(a′))), (i, j)) + + # `replaceinds` is a name-only synonym for the pair-based relabel. + k, l = Index.((2, 3)) + a_r = replaceinds(a, i => k, j => l) + @test unnamed(a_r) == unnamed(a) + @test issetequal(inds(a_r), (k, l)) + + # `sim` mints fresh ids, so no index of `sim(a)` matches an index of `a`, while the + # data, lengths, tags, and prime levels are preserved. + a_s = sim(a) + @test unnamed(a_s) == unnamed(a) + @test !any(in(inds(a)), inds(a_s)) + @test issetequal(length.(inds(a_s)), length.(inds(a))) + + i2 = settag(prime(Index(2)), "X", "Y") + @test sim(i2) != i2 + @test plev(sim(i2)) == 1 + @test gettag(sim(i2), "X") == "Y" + @test length(sim(i2)) == 2 + end + @testset "rank-0 similar and one" begin + elt = Float64 + i, j = Index.((2, 3)) + a = randn(elt, i, j) + + # `similar(a, ())` mints a scalar (0-dim) tensor on `a`'s backend and element type. + s = similar(a, ()) + @test s isa AbstractNamedTensor + @test eltype(s) === elt + @test isempty(inds(s)) + @test ndims(unnamed(s)) == 0 + fill!(s, 1) + @test unnamed(s)[] == 1 + + s32 = similar(a, Float32, ()) + @test eltype(s32) === Float32 + @test isempty(inds(s32)) + + # `one(a)` is a rank-0 tensor holding `one(eltype(a))`. + o = one(a) + @test o isa AbstractNamedTensor + @test eltype(o) === elt + @test isempty(inds(o)) + @test unnamed(o)[] == one(elt) + end end diff --git a/test/test_exports.jl b/test/test_exports.jl index 9a890c3..0ab9602 100644 --- a/test/test_exports.jl +++ b/test/test_exports.jl @@ -6,8 +6,8 @@ using Test: @test, @testset :Index, :NamedUnitRange, :aligndims, :aligneddims, :apply, :codomainnames, :dimnames, :dimnametype, :domainnames, - :inds, :named, :nameddims, :noprime, :operator, :prime, :similar_operator, - :state, :uniquename, + :inds, :mapinds, :named, :nameddims, :noprime, :operator, :prime, :replaceinds, + :sim, :similar_operator, :state, :uniquename, ] publics = [ :IndexName, :name, :nametype, :replacedimnames, :setname, :unnamed, From bfe7395f485fdaee832bd5c9de186bdb25da4a04 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Wed, 8 Jul 2026 15:04:30 -0400 Subject: [PATCH 03/15] Add name-based index-set algebra for AbstractNamedTensor Adds `commoninds`, `uniqueinds`, `unioninds`, `noncommoninds`, and `hascommoninds` on `AbstractNamedTensor`, along with the singular `commonind`/`uniqueind` and their non-erroring `trycommonind`/`trynoncommonind` counterparts. Indices are compared by name, so a shared bond matches even when the two tensors carry it as an index on one and its dual on the other. --- src/ITensorBase.jl | 7 ++- src/abstractnamedtensor.jl | 126 +++++++++++++++++++++++++++++++++++++ test/test_basics.jl | 34 ++++++++++ test/test_exports.jl | 10 +-- 4 files changed, 170 insertions(+), 7 deletions(-) diff --git a/src/ITensorBase.jl b/src/ITensorBase.jl index 4d19eae..94479b1 100644 --- a/src/ITensorBase.jl +++ b/src/ITensorBase.jl @@ -1,9 +1,10 @@ module ITensorBase export AbstractNamedTensor, NamedTensor, AbstractITensor, ITensor, Index, - NamedUnitRange, aligndims, aligneddims, apply, codomainnames, dimnames, - dimnametype, domainnames, inds, mapinds, named, nameddims, noprime, operator, - prime, replaceinds, sim, similar_operator, state, uniquename + NamedUnitRange, aligndims, aligneddims, apply, codomainnames, commonind, commoninds, + dimnames, dimnametype, domainnames, hascommoninds, inds, mapinds, named, nameddims, + noncommoninds, noprime, operator, prime, replaceinds, sim, similar_operator, state, + trycommonind, trynoncommonind, uniqueind, uniqueinds, unioninds, uniquename using Compat: @compat @compat public @names @compat public IndexName, name, nametype, replacedimnames, setname, unnamed, unnamedtype diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index 1a42ac7..02fad20 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -545,6 +545,132 @@ See also [`replaceinds`](@ref). """ mapinds(f, a::AbstractNamedTensor) = replaceinds(f, a) +# Name-based index-set algebra (the `commoninds`/etc. surface). Layered in three: +# small order-preserving set ops on arbitrary collections, the same ops keyed by index +# name, and the tensor-family functions built on top. + +# Small-collection set operations keyed by a transform `by` (elements compare equal when +# `by(x) == by(y)`). These scan linearly rather than building `Set`s: Base's `Set`-based ops +# hash, and hashing a whole `Index` can be expensive or fall back to iterating a graded axis. +# The intersect/setdiff/union/symdiff forms return elements of the first argument as `Vector`s. +function smallintersect(a, b; by = identity) + return (kb = Iterators.map(by, b); [x for x in a if by(x) ∈ kb]) +end +function smallsetdiff(a, b; by = identity) + return (kb = Iterators.map(by, b); [x for x in a if by(x) ∉ kb]) +end +smallunion(a, b; by = identity) = vcat(collect(a), smallsetdiff(b, a; by)) +smallsymdiff(a, b; by = identity) = vcat(smallsetdiff(a, b; by), smallsetdiff(b, a; by)) +smallisdisjoint(a, b; by = identity) = (kb = Iterators.map(by, b); !any(x -> by(x) ∈ kb, a)) +smallissubset(a, b; by = identity) = (kb = Iterators.map(by, b); all(x -> by(x) ∈ kb, a)) +smallissetequal(a, b; by = identity) = smallissubset(a, b; by) && smallissubset(b, a; by) + +# The small ops keyed by index name (`by = name`) rather than full `Index` equality. On a +# graded axis a shared bond appears as an index on one tensor and its dual (`conj`) on the +# other (same name, opposite arrow), so full-`Index` `==` misses it while the names match. +# On the dense backend the two coincide. +nameintersect(a, b) = smallintersect(a, b; by = name) +namesetdiff(a, b) = smallsetdiff(a, b; by = name) +nameunion(a, b) = smallunion(a, b; by = name) +namesymdiff(a, b) = smallsymdiff(a, b; by = name) +nameisdisjoint(a, b) = smallisdisjoint(a, b; by = name) +nameissubset(a, b) = smallissubset(a, b; by = name) +nameissetequal(a, b) = smallissetequal(a, b; by = name) + +""" + commoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The indices shared by name between `a` and `b`, as a `Vector` in the order they appear in `a`. + +See also [`commonind`](@ref), [`uniqueinds`](@ref), [`hascommoninds`](@ref). +""" +commoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) = nameintersect(inds(a), inds(b)) + +""" + uniqueinds(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The indices of `a` that do not appear by name in `b`, as a `Vector` in the order they appear +in `a`. + +See also [`uniqueind`](@ref), [`commoninds`](@ref), [`noncommoninds`](@ref). +""" +uniqueinds(a::AbstractNamedTensor, b::AbstractNamedTensor) = namesetdiff(inds(a), inds(b)) + +""" + unioninds(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The union by name of the indices of `a` and `b`, as a `Vector`: the indices of `a` followed +by the indices of `b` not already present in `a`. + +See also [`commoninds`](@ref), [`noncommoninds`](@ref). +""" +unioninds(a::AbstractNamedTensor, b::AbstractNamedTensor) = nameunion(inds(a), inds(b)) + +""" + noncommoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The indices not shared by name between `a` and `b` (the symmetric difference), as a `Vector`: +the indices unique to `a` followed by those unique to `b`. + +See also [`uniqueinds`](@ref), [`commoninds`](@ref). +""" +function noncommoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) + return namesymdiff(inds(a), inds(b)) +end + +""" + hascommoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) + +Whether `a` and `b` share any index by name. + +See also [`commoninds`](@ref). +""" +function hascommoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) + return !nameisdisjoint(inds(a), inds(b)) +end + +""" + commonind(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The single index shared by name between `a` and `b`. Errors unless there is exactly one +shared index. Use [`trycommonind`](@ref) to get `nothing` instead of an error. + +See also [`commoninds`](@ref), [`uniqueind`](@ref). +""" +commonind(a::AbstractNamedTensor, b::AbstractNamedTensor) = only(commoninds(a, b)) + +""" + uniqueind(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The single index of `a` that does not appear by name in `b`. Errors unless there is exactly +one such index. Use [`trynoncommonind`](@ref) to get `nothing` instead of an error. + +See also [`uniqueinds`](@ref), [`commonind`](@ref). +""" +uniqueind(a::AbstractNamedTensor, b::AbstractNamedTensor) = only(uniqueinds(a, b)) + +""" + trycommonind(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The single index shared by name between `a` and `b`, or `nothing` if they share no index or +more than one. The non-erroring counterpart of [`commonind`](@ref). +""" +function trycommonind(a::AbstractNamedTensor, b::AbstractNamedTensor) + cs = commoninds(a, b) + return length(cs) == 1 ? only(cs) : nothing +end + +""" + trynoncommonind(a::AbstractNamedTensor, b::AbstractNamedTensor) + +The single index of `a` that does not appear by name in `b`, or `nothing` if there is no such +index or more than one. The non-erroring counterpart of [`uniqueind`](@ref). +""" +function trynoncommonind(a::AbstractNamedTensor, b::AbstractNamedTensor) + us = uniqueinds(a, b) + return length(us) == 1 ? only(us) : nothing +end + # `Base.isempty(a::AbstractArray)` is defined as `length(a) == 0`, # which involves comparing a named integer to an unnamed integer # which isn't well defined. diff --git a/test/test_basics.jl b/test/test_basics.jl index ca6c45c..6267324 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -232,4 +232,38 @@ using UUIDs: UUID @test isempty(inds(o)) @test unnamed(o)[] == one(elt) end + @testset "name-based index-set algebra" begin + elt = Float64 + i, j, k = Index.((2, 3, 4)) + a = randn(elt, i, j) + b = randn(elt, j, k) + + namesof(is) = name.(is) + + @test namesof(commoninds(a, b)) == namesof([j]) + @test namesof(uniqueinds(a, b)) == namesof([i]) + @test namesof(unioninds(a, b)) == namesof([i, j, k]) + @test namesof(noncommoninds(a, b)) == namesof([i, k]) + @test hascommoninds(a, b) + + @test name(commonind(a, b)) == name(j) + @test name(uniqueind(a, b)) == name(i) + @test name(trycommonind(a, b)) == name(j) + @test name(trynoncommonind(a, b)) == name(i) + + # No shared index. + c = randn(elt, k) + @test isempty(commoninds(a, c)) + @test !hascommoninds(a, c) + @test isnothing(trycommonind(a, c)) + + # `commonind`/`uniqueind` error unless there is exactly one; the `try*` forms + # return `nothing` instead. + d = randn(elt, i, j) + @test_throws ArgumentError commonind(a, d) + @test isnothing(trycommonind(a, d)) + @test_throws ArgumentError commonind(a, c) + @test_throws ArgumentError uniqueind(a, c) + @test isnothing(trynoncommonind(a, c)) + end end diff --git a/test/test_exports.jl b/test/test_exports.jl index 0ab9602..6375438 100644 --- a/test/test_exports.jl +++ b/test/test_exports.jl @@ -4,10 +4,12 @@ using Test: @test, @testset exports = [ :ITensorBase, :AbstractNamedTensor, :NamedTensor, :AbstractITensor, :ITensor, :Index, :NamedUnitRange, - :aligndims, :aligneddims, :apply, :codomainnames, :dimnames, :dimnametype, - :domainnames, - :inds, :mapinds, :named, :nameddims, :noprime, :operator, :prime, :replaceinds, - :sim, :similar_operator, :state, :uniquename, + :aligndims, :aligneddims, :apply, :codomainnames, :commonind, :commoninds, + :dimnames, :dimnametype, :domainnames, :hascommoninds, + :inds, :mapinds, :named, :nameddims, :noncommoninds, :noprime, :operator, + :prime, + :replaceinds, :sim, :similar_operator, :state, :trycommonind, :trynoncommonind, + :uniqueind, :uniqueinds, :unioninds, :uniquename, ] publics = [ :IndexName, :name, :nametype, :replacedimnames, :setname, :unnamed, From e940ca52c1dc55aa5066f6ef5a85e013978ad59a Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Wed, 8 Jul 2026 15:19:53 -0400 Subject: [PATCH 04/15] Apply replaceinds/mapinds function form at the index level The function form of `replaceinds`/`mapinds` forwarded straight to `replacedimnames`, so its `f` received a dimension name rather than the index its docstring describes. Apply `f` to each index instead, relabeling name-only through the pair form, so `replaceinds`/`mapinds` are the index-level counterpart of the name-level `replacedimnames`/`mapdimnames`. --- src/abstractnamedtensor.jl | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index 02fad20..2c07fda 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -520,26 +520,26 @@ mapdimnames(f, a::AbstractNamedTensor) = replacedimnames(f, a) replaceinds(a::AbstractNamedTensor, replacements::Pair...) replaceinds(f, a::AbstractNamedTensor) -Return a tensor with the same data as `a` but with its indices relabeled, a backend-agnostic -synonym for [`replacedimnames`](@ref). This is a name-only relabel: it rewrites the index -names and leaves the underlying space untouched (replacing the space instead would -scalar-index a graded axis). The first form takes `old => new` pairs, relabeling matching -indices and leaving the rest unchanged; the second replaces each index with `f(index)`. +Return a tensor with the same data as `a` but with its indices relabeled. Unlike +[`replacedimnames`](@ref), whose function `f` receives a dimension name, `replaceinds` works +at the index level: the pair form takes `old => new` index pairs, and the function form +relabels each index `i` using `f(i)`. Either way this is a name-only relabel, taking just the +name of the replacement and leaving the underlying space untouched (replacing the space +instead would scalar-index a graded axis). Pairs whose index is absent are ignored. See also [`mapinds`](@ref), [`replacedimnames`](@ref). """ function replaceinds(a::AbstractNamedTensor, replacements::Pair...) return replacedimnames(a, replacements...) end -replaceinds(f, a::AbstractNamedTensor) = replacedimnames(f, a) +replaceinds(f, a::AbstractNamedTensor) = replaceinds(a, map(i -> i => f(i), inds(a))...) """ mapinds(f, a::AbstractNamedTensor) -Return a tensor with the same data as `a` but with each of its indices replaced by -`f(index)`, a name-only relabel that leaves the underlying space untouched. This is the -whole-tensor index-map primitive behind [`prime`](@ref), [`noprime`](@ref), and -[`sim`](@ref). +Return a tensor with the same data as `a` but with each index `i` relabeled using `f(i)`, a +name-only relabel that leaves the underlying space untouched. This is the whole-tensor +index-map primitive behind [`prime`](@ref), [`noprime`](@ref), and [`sim`](@ref). See also [`replaceinds`](@ref). """ From 7055ae212ea3124f6f10e2b6b494afc3d842d190 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Wed, 8 Jul 2026 15:20:11 -0400 Subject: [PATCH 05/15] Rename the IndexName identifier accessor to uuid and add the id identity constructor Rename the unique-identifier accessor `id` on `IndexName`/`Index` to `uuid` (along with the struct field, the constructor keyword, and the `setuuid` setter), freeing the `id` name for the identity-tensor constructor. Add `id(elt, codomain, domain)`, the from-scratch identity operator over the given codomain and domain indices, a sibling of `one(a, codomain, domain)` that needs only the indices and an element type rather than a prototype tensor. `id` is exported. Breaking: `ITensorBase.id` on an index or index name is now `ITensorBase.uuid`. --- src/ITensorBase.jl | 2 +- src/index.jl | 29 +++++++++++++++-------------- src/tensoralgebra.jl | 33 +++++++++++++++++++++++++++++++++ test/test_basics.jl | 22 +++++++++++----------- test/test_exports.jl | 2 +- test/test_operator.jl | 15 ++++++++++++++- 6 files changed, 75 insertions(+), 28 deletions(-) diff --git a/src/ITensorBase.jl b/src/ITensorBase.jl index 94479b1..1070bf2 100644 --- a/src/ITensorBase.jl +++ b/src/ITensorBase.jl @@ -2,7 +2,7 @@ module ITensorBase export AbstractNamedTensor, NamedTensor, AbstractITensor, ITensor, Index, NamedUnitRange, aligndims, aligneddims, apply, codomainnames, commonind, commoninds, - dimnames, dimnametype, domainnames, hascommoninds, inds, mapinds, named, nameddims, + dimnames, dimnametype, domainnames, hascommoninds, id, inds, mapinds, named, nameddims, noncommoninds, noprime, operator, prime, replaceinds, sim, similar_operator, state, trycommonind, trynoncommonind, uniqueind, uniqueinds, unioninds, uniquename using Compat: @compat diff --git a/src/index.jl b/src/index.jl index 895be4a..1273aa7 100644 --- a/src/index.jl +++ b/src/index.jl @@ -23,15 +23,15 @@ is the dimension-name type behind the legacy ITensor surface, where `Index` is `NamedUnitRange{IndexName}` and [`ITensor`](@ref) is `NamedTensor{IndexName}`. """ struct IndexName <: AbstractName - id::UUID + uuid::UUID tags::SortedDict{Symbol, Symbol} plev::Int end function IndexName( - rng::AbstractRNG = RandomDevice(); id::UUID = uuid4(rng), + rng::AbstractRNG = RandomDevice(); uuid::UUID = uuid4(rng), tags = (), plev::Int = 0 ) - return IndexName(id, to_tags(tags), plev) + return IndexName(uuid, to_tags(tags), plev) end # `uniquename` on an existing `IndexName` keeps its tags and prime level, minting only a # fresh id (the legacy `sim`). The type form drops them: a factorization bond or a fresh @@ -56,7 +56,7 @@ to_symbol_pair(p::Pair) = Symbol(first(p)) => Symbol(last(p)) to_tags(ps::Pair...) = to_tags(ps) to_tags(tags) = SortedDict{Symbol, Symbol}(to_symbol_pair(p) for p in tags) -id(n::IndexName) = getfield(n, :id) +uuid(n::IndexName) = getfield(n, :uuid) # Internal: the stored tags as `Symbol => Symbol`, used by the hot comparison, # hashing, and display paths. `tags` is the public string-valued view of this. @@ -80,27 +80,28 @@ end plev(n::IndexName) = getfield(n, :plev) function Base.:(==)(n1::IndexName, n2::IndexName) - return id(n1) == id(n2) && plev(n1) == plev(n2) && tags_stored(n1) == tags_stored(n2) + return uuid(n1) == uuid(n2) && plev(n1) == plev(n2) && + tags_stored(n1) == tags_stored(n2) end function Base.isequal(n1::IndexName, n2::IndexName) - return isequal(id(n1), id(n2)) && + return isequal(uuid(n1), uuid(n2)) && isequal(plev(n1), plev(n2)) && isequal(tags_stored(n1), tags_stored(n2)) end function Base.isless(n1::IndexName, n2::IndexName) - t1 = (id(n1), plev(n1), keys(tags_stored(n1)), values(tags_stored(n1))) - t2 = (id(n2), plev(n2), keys(tags_stored(n2)), values(tags_stored(n2))) + t1 = (uuid(n1), plev(n1), keys(tags_stored(n1)), values(tags_stored(n1))) + t2 = (uuid(n2), plev(n2), keys(tags_stored(n2)), values(tags_stored(n2))) return isless(t1, t2) end function Base.hash(n::IndexName, h::UInt) h = hash(:IndexName, h) - h = hash(id(n), h) + h = hash(uuid(n), h) h = hash(plev(n), h) h = hash(tags_stored(n), h) return h end -setid(n::IndexName, id) = @set n.id = id +setuuid(n::IndexName, uuid) = @set n.uuid = uuid settags(n::IndexName, tags) = @set n.tags = tags setplev(n::IndexName, plev) = @set n.plev = plev @@ -203,10 +204,10 @@ sim(n::IndexName) = uniquename(n) # enough to disambiguate indices at a glance without dominating the output. A # leading prefix (here the first hyphen-delimited group) is the usual short-id # convention, as in git short hashes and Docker short ids. -shortid(id::UUID) = first(string(id), 8) +shortid(uuid::UUID) = first(string(uuid), 8) function Base.show(io::IO, i::IndexName) - idstr = "id=$(shortid(id(i)))" + idstr = "id=$(shortid(uuid(i)))" tagsstr = !isempty(tags_stored(i)) ? "|$(tagsstring(tags_stored(i)))" : "" primestr = primestring(plev(i)) str = "IndexName($(idstr)$(tagsstr))$(primestr)" @@ -263,7 +264,7 @@ const ITensor = NamedTensor{IndexName} const ITensorOperator = NamedTensorOperator{IndexName} # TODO: Define for `NamedViewIndex`. -id(i::Index) = id(name(i)) +uuid(i::Index) = uuid(name(i)) tags_stored(i::Index) = tags_stored(name(i)) tags(i::Index) = tags(name(i)) plev(i::Index) = plev(name(i)) @@ -303,7 +304,7 @@ end function Base.show(io::IO, i::Index) lenstr = "length=$(length(i))" - idstr = "|id=$(shortid(id(i)))" + idstr = "|id=$(shortid(uuid(i)))" tagsstr = !isempty(tags_stored(i)) ? "|$(tagsstring(tags_stored(i)))" : "" primestr = primestring(plev(i)) str = "Index($(lenstr)$(idstr)$(tagsstr))$(primestr)" diff --git a/src/tensoralgebra.jl b/src/tensoralgebra.jl index dad4190..84f5259 100644 --- a/src/tensoralgebra.jl +++ b/src/tensoralgebra.jl @@ -599,6 +599,39 @@ function one_nameddims( return nameddims(raw, (codomain..., domain...)) end +""" + id(elt::Type, codomain, domain) -> Id + +Construct a from-scratch identity-operator-shaped named tensor over the `codomain` and +`domain` indices, with element type `elt`. The fused codomain and domain sizes must match. +Unlike [`one`](@ref), which follows a prototype tensor, `id` needs only the indices and an +element type, so it is the right primitive when no prototype is in hand. The index axes select +the backend: dense ranges give a dense tensor, graded ranges a block-sparse one. + +# Examples + +```jldoctest +julia> using ITensorBase: apply, id, namedoneto, operator + +julia> i, j, k, l = namedoneto.((2, 3, 2, 3), ("i", "j", "k", "l")); + +julia> Id = operator(id(Float64, (i, j), (k, l)), ("i", "j"), ("k", "l")); + +julia> v = randn(k, l); + +julia> apply(Id, v) ≈ v +true +``` + +See also [`one`](@ref). +""" +function id(elt::Type, codomain, domain) + codomain, domain = Tuple(codomain), Tuple(domain) + m = Matrix{elt}(LA.I, prod(length, codomain), prod(length, domain)) + axissizes = (length.(codomain)..., length.(domain)...) + return TA.project(reshape(m, axissizes), codomain, domain) +end + const MATRIX_FUNCTIONS = [ :exp, :cis, :log, :sqrt, :cbrt, :cos, :sin, :tan, :csc, :sec, :cot, :cosh, :sinh, :tanh, diff --git a/test/test_basics.jl b/test/test_basics.jl index 6267324..4e5a9a4 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -1,27 +1,27 @@ using ITensorBase: ITensorBase, AbstractNamedTensor, ITensor, Index, IndexName, NamedTensor, dimnametype, gettag, hastag, id, inds, mapinds, name, named, noprime, plev, prime, - replaceinds, setplev, settag, sim, tags, uniquename, unname, unnamed, unsettag + replaceinds, setplev, settag, sim, tags, uniquename, unname, unnamed, unsettag, uuid using Test: @test, @test_broken, @test_throws, @testset using UUIDs: UUID @testset "ITensorBase" begin @testset "IndexName" begin - n1 = IndexName(; id = UUID(0)) - n2 = IndexName(; id = UUID(0)) + n1 = IndexName(; uuid = UUID(0)) + n2 = IndexName(; uuid = UUID(0)) @test n1 == n2 @test isequal(n1, n2) @test hash(n1) ≡ hash(n2) - n1 = IndexName(; id = UUID(0)) - n2 = IndexName(; id = UUID(1)) + n1 = IndexName(; uuid = UUID(0)) + n2 = IndexName(; uuid = UUID(1)) @test n1 ≠ n2 @test !isequal(n1, n2) @test n1 < n2 @test isless(n1, n2) @test hash(n1) ≠ hash(n2) - n1 = IndexName(; id = UUID(0), plev = 0) - n2 = IndexName(; id = UUID(0), plev = 1) + n1 = IndexName(; uuid = UUID(0), plev = 0) + n2 = IndexName(; uuid = UUID(0), plev = 1) @test n1 ≠ n2 @test !isequal(n1, n2) @test n1 < n2 @@ -49,10 +49,10 @@ using UUIDs: UUID end @testset "uniquename" begin i = settag(prime(Index(2)), "X", "Y") - # On an instance, only the id is fresh: tags and prime level are kept. + # On an instance, only the uuid is fresh: tags and prime level are kept. n = uniquename(name(i)) @test n != name(i) - @test id(n) != id(name(i)) + @test uuid(n) != uuid(name(i)) @test plev(n) == plev(i) @test tags(n) == tags(i) # On the name type, a bare name: no tags, prime level zero. @@ -170,11 +170,11 @@ using UUIDs: UUID @testset "show" begin i = Index(2) @test sprint(show, "text/plain", i) == - "Index(length=2|id=$(first(string(id(i)), 8)))" + "Index(length=2|id=$(first(string(uuid(i)), 8)))" i = settag(Index(2), "X", "Y") @test sprint(show, "text/plain", i) == - "Index(length=2|id=$(first(string(id(i)), 8))|X=>Y)" + "Index(length=2|id=$(first(string(uuid(i)), 8))|X=>Y)" end @testset "whole-tensor index manipulation" begin elt = Float64 diff --git a/test/test_exports.jl b/test/test_exports.jl index 6375438..b2db891 100644 --- a/test/test_exports.jl +++ b/test/test_exports.jl @@ -5,7 +5,7 @@ using Test: @test, @testset :ITensorBase, :AbstractNamedTensor, :NamedTensor, :AbstractITensor, :ITensor, :Index, :NamedUnitRange, :aligndims, :aligneddims, :apply, :codomainnames, :commonind, :commoninds, - :dimnames, :dimnametype, :domainnames, :hascommoninds, + :dimnames, :dimnametype, :domainnames, :hascommoninds, :id, :inds, :mapinds, :named, :nameddims, :noncommoninds, :noprime, :operator, :prime, :replaceinds, :sim, :similar_operator, :state, :trycommonind, :trynoncommonind, diff --git a/test/test_operator.jl b/test/test_operator.jl index 5341db9..cfb08a7 100644 --- a/test/test_operator.jl +++ b/test/test_operator.jl @@ -1,5 +1,5 @@ using ITensorBase: ITensorBase as NDA, NamedTensor, NamedTensorOperator, apply, - codomainnames, dimnames, domainnames, nameddims, namedoneto, operator, product, + codomainnames, dimnames, domainnames, id, nameddims, namedoneto, operator, product, replacedimnames, similar_operator, state, unname, unnamed using LinearAlgebra: I, norm using Random: Random @@ -84,6 +84,19 @@ end @test unname(Id_mat, ("row", "col")) ≈ I(8) end +@testset "id(elt, codomain, domain)" begin + # From-scratch identity map (no prototype): matricized form is the identity matrix. + i, j, k, l = namedoneto.((2, 3, 2, 3), ("i", "j", "k", "l")) + Id = id(Float64, (i, j), (k, l)) + @test eltype(Id) === Float64 + @test issetequal(dimnames(Id), ("i", "j", "k", "l")) + Id_mat = matricize(Id, (i, j) => "row", (k, l) => "col") + @test unname(Id_mat, ("row", "col")) ≈ I(6) + + # The requested element type is honored. + @test eltype(id(ComplexF64, (i, j), (k, l))) === ComplexF64 +end + @testset "similar_operator" begin # Five-arg canonical: explicit element type, axes, codomain, domain names. op = similar_operator(randn(3, 3), Float32, (Base.OneTo(3),), ("i'",), ("i",)) From 02e184ac5f58505291007f3be944b3acf8c9b337 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Wed, 8 Jul 2026 17:37:50 -0400 Subject: [PATCH 06/15] Use name in place of the to_dimname helper `name` already strips an index to its dimension name and passes a bare name through unchanged, so `to_dimname` duplicated it. Call `name` directly when normalizing relabel pairs, which handles either an index or a bare name on either side of the pair. --- src/abstractnamedtensor.jl | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index 2c07fda..a2a3ba3 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -500,13 +500,12 @@ julia> dimnames(replacedimnames(a, :i => :k)) See also [`dimnames`](@ref). """ function replacedimnames end -# Strip an `Index`/`NamedUnitRange` down to its dimension name so an index-keyed pair -# (`i => j`) relabels like the name-keyed pair (`name(i) => name(j)`); `dimnames(a)` holds -# names, so a raw-index key would never match and silently no-op. -to_dimname(x) = x -to_dimname(x::NamedUnitRange) = name(x) +# `name` strips an `Index`/`NamedUnitRange` to its dimension name and passes a bare name +# through unchanged, so an index-keyed pair (`i => j`) relabels like the name-keyed pair +# (`name(i) => name(j)`). `dimnames(a)` holds names, so a raw-index key would never match and +# silently no-op. function replacedimnames(a::AbstractNamedTensor, replacements::Pair...) - replacements = map(p -> to_dimname(first(p)) => to_dimname(last(p)), replacements) + replacements = map(p -> name(first(p)) => name(last(p)), replacements) new_dimnames = replace(dimnames(a), replacements...) return nameddims(unnamed(a), new_dimnames) end From 9e81ff24117527af74475ecffbf9a5531d777945 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 09:50:02 -0400 Subject: [PATCH 07/15] Add tr over a codomain/domain bipartition and a map-shaped similar Adds `LinearAlgebra.tr(a, codomain, domain)` for an `AbstractNamedTensor`, plus a `NamedTensorOperator` method, the trace of a named tensor viewed as a map. It pairs each codomain index with the domain index in the same position and sums the diagonal. The contracting identity is built over the dual axes with `similar` and `one!`, so the trace follows the backend (dense, graded, or TensorMap). Adds a map-shaped `Base.similar(a, codomain, domain)` carrying a nontrivial domain, so a graded or TensorMap backend allocates the correct codomain-to-domain block structure. The existing single-axis-tuple forms are all-codomain. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/abstractnamedtensor.jl | 27 +++++++++++++++++++-------- src/namedtensoroperator.jl | 15 +++++++++++++++ src/tensoralgebra.jl | 27 +++++++++++++++++++++++++++ test/test_basics.jl | 9 +-------- test/test_tensoralgebra.jl | 19 ++++++++++++++++--- 5 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index a2a3ba3..c5aae83 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -463,14 +463,25 @@ function Base.similar(a::AbstractNamedTensor, elt::Type, inds::Tuple{}) return similar_nameddims(a, elt, inds) end -""" - one(a::AbstractNamedTensor) -> AbstractNamedTensor - -Return a rank-0 (scalar) tensor holding `one(scalartype(a))` on `a`'s backend. This is the -multiplicative unit matching `a`'s element type and backend (dense, graded, `TensorMap`, …), -useful as the seed of a product accumulator. -""" -Base.one(a::AbstractNamedTensor) = fill!(similar(a, ()), one(scalartype(a))) +# Map-shaped allocator: a shell over the `codomain`/`domain` named axes following `a`'s +# backend, with the domain axes dualized in storage (as `TensorAlgebra.similar_map` does). +# Unlike the single-axis-tuple forms above (all-codomain), this carries a nontrivial domain, +# so a graded / `TensorMap` backend allocates the correct `codomain ← domain` block structure. +function Base.similar( + a::AbstractNamedTensor, + codomain::Tuple{Vararg{NamedUnitRange}}, + domain::Tuple{Vararg{NamedUnitRange}} + ) + return similar(a, eltype(a), codomain, domain) +end +function Base.similar( + a::AbstractNamedTensor, elt::Type, + codomain::Tuple{Vararg{NamedUnitRange}}, + domain::Tuple{Vararg{NamedUnitRange}} + ) + raw = TensorAlgebra.similar_map(unnamed(a), elt, unnamed.(codomain), unnamed.(domain)) + return nameddims(raw, (name.(codomain)..., name.(domain)...)) +end function setdimnames(a::AbstractNamedTensor, dimnames) return nameddims(unnamed(a), dimnames) diff --git a/src/namedtensoroperator.jl b/src/namedtensoroperator.jl index c5f8ebe..f5c3526 100644 --- a/src/namedtensoroperator.jl +++ b/src/namedtensoroperator.jl @@ -1,4 +1,5 @@ using Base.Broadcast: Broadcast as BC, Broadcasted +using LinearAlgebra: LinearAlgebra as LA using OrderedCollections: OrderedDict using Random: Random @@ -469,6 +470,20 @@ function Base.one(op::NamedTensorOperator) return operator(one(state(op), co, dom), co, dom) end +""" + LinearAlgebra.tr(op::NamedTensorOperator) -> scalar + +Trace of a named operator: contracts each codomain index with its paired domain index and +sums the diagonal, over the operator's intrinsic codomain/domain split. +""" +function LA.tr(op::NamedTensorOperator) + a = state(op) + byname = Dict(name(i) => i for i in inds(a)) + codomain = map(n -> byname[n], Tuple(codomainnames(op))) + domain = map(n -> byname[n], Tuple(domainnames(op))) + return LA.tr(a, codomain, domain) +end + # === similar_operator === # # Allocate an operator with the user-supplied axes as the domain (input). The diff --git a/src/tensoralgebra.jl b/src/tensoralgebra.jl index 84f5259..d282fd2 100644 --- a/src/tensoralgebra.jl +++ b/src/tensoralgebra.jl @@ -632,6 +632,33 @@ function id(elt::Type, codomain, domain) return TA.project(reshape(m, axissizes), codomain, domain) end +""" + LinearAlgebra.tr(a::AbstractNamedTensor, codomain, domain) -> scalar + +Trace of `a` viewed as a map, pairing each `codomain` index with the `domain` index in the +same position (matching sizes) and summing the diagonal. `codomain` and `domain` together +must cover all of `a`'s indices, so the result is a scalar. Contracts `a` against an identity +built over the dual axes (`similar` a shell over the conjugated codomain/domain, filled by +`one!`), so it follows `a`'s backend (dense, graded, or `TensorMap`). + +# Examples + +```jldoctest +julia> using ITensorBase: id, namedoneto + +julia> i, j, k, l = namedoneto.((2, 3, 2, 3), ("i", "j", "k", "l")); + +julia> tr(id(Float64, (i, j), (k, l)), (i, j), (k, l)) +6.0 +``` +""" +function LA.tr(a::AbstractNamedTensor, codomain, domain) + codomain, domain = Tuple(codomain), Tuple(domain) + Id = similar(a, conj.(codomain), conj.(domain)) + TA.one!(unnamed(Id), Val(length(codomain))) + return (a * Id)[] +end + const MATRIX_FUNCTIONS = [ :exp, :cis, :log, :sqrt, :cbrt, :cos, :sin, :tan, :csc, :sec, :cot, :cosh, :sinh, :tanh, diff --git a/test/test_basics.jl b/test/test_basics.jl index 4e5a9a4..030a383 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -207,7 +207,7 @@ using UUIDs: UUID @test gettag(sim(i2), "X") == "Y" @test length(sim(i2)) == 2 end - @testset "rank-0 similar and one" begin + @testset "rank-0 similar" begin elt = Float64 i, j = Index.((2, 3)) a = randn(elt, i, j) @@ -224,13 +224,6 @@ using UUIDs: UUID s32 = similar(a, Float32, ()) @test eltype(s32) === Float32 @test isempty(inds(s32)) - - # `one(a)` is a rank-0 tensor holding `one(eltype(a))`. - o = one(a) - @test o isa AbstractNamedTensor - @test eltype(o) === elt - @test isempty(inds(o)) - @test unnamed(o)[] == one(elt) end @testset "name-based index-set algebra" begin elt = Float64 diff --git a/test/test_tensoralgebra.jl b/test/test_tensoralgebra.jl index bdd20ea..e423124 100644 --- a/test/test_tensoralgebra.jl +++ b/test/test_tensoralgebra.jl @@ -1,6 +1,6 @@ -using ITensorBase: ITensorBase, Index, dimnames, inds, name, namedoneto, prime, - replacedimnames, uniquename, unname, unnamed -using LinearAlgebra: LinearAlgebra, norm +using ITensorBase: ITensorBase, Index, dimnames, id, inds, name, namedoneto, operator, + prime, replacedimnames, uniquename, unname, unnamed +using LinearAlgebra: LinearAlgebra, norm, tr using MatrixAlgebraKit: left_null, left_orth, left_polar, lq_compact, lq_full, qr_compact, qr_full, right_null, right_orth, right_polar, svd_compact, svd_trunc, svd_vals using StableRNGs: StableRNG @@ -165,6 +165,19 @@ using Test: @test, @test_broken, @testset @test YXmat ≈ LinearAlgebra.I(size(YXmat, 1)) end end + @testset "tr" begin + i, j = namedoneto.((2, 3), ("i", "j")) + ip, jp = prime(i), prime(j) + a = randn(elt, i, j, ip, jp) + # The trace pairs (i, j) with (ip, jp), matching the dense matrix trace of the + # matricized map. + @test tr(a, (i, j), (ip, jp)) ≈ tr(reshape(unname(a, (i, j, ip, jp)), 6, 6)) + # The identity map traces to its (shared) fused dimension. + @test tr(id(elt, (i, j), (ip, jp)), (i, j), (ip, jp)) ≈ 6 + # The operator form traces over its intrinsic codomain/domain split. + op = operator(a, (name(i), name(j)), (name(ip), name(jp))) + @test tr(op) ≈ tr(a, (i, j), (ip, jp)) + end @testset "project" begin i = Index(2) Sz = elt[0.5 0; 0 -0.5] From 9bb45b37ca4c2da4446f8331cf6253b7bd440f22 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 11:40:08 -0400 Subject: [PATCH 08/15] Make NamedUnitRange equality dual-insensitive and forward tr to TensorAlgebra.tr Equality, isequal, and hash on NamedUnitRange now key on the name plus the axis's ungraded extent (via TensorAlgebra.ungrade) rather than the full range. Conjugation preserves the name and the ungraded extent while flipping arrows and charge labels, so an index compares equal to its dual and stock Base set-ops, Dict, and Set treat the two as the same leg. isequal delegates to ==. tr on a named tensor now forwards to TensorAlgebra.tr on the unnamed data, which matricizes into the square matrix and takes the matrix trace, replacing the earlier identity contraction. The two agree for bosons and differ for fermions by the twist sign, and the matricized trace is the plain operator trace. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/namedunitrange.jl | 20 +++++++++++++++++--- src/tensoralgebra.jl | 10 ++++------ test/test_basics.jl | 6 ++++-- test/test_tensoralgebra.jl | 2 +- test/test_tensorkitext.jl | 9 +++++++++ 5 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/namedunitrange.jl b/src/namedunitrange.jl index b2dfca4..0659fdb 100644 --- a/src/namedunitrange.jl +++ b/src/namedunitrange.jl @@ -1,4 +1,4 @@ -using TensorAlgebra: TensorAlgebra, to_range, trivialrange +using TensorAlgebra: TensorAlgebra, to_range, trivialrange, ungrade """ NamedUnitRange{Name} @@ -78,11 +78,25 @@ named(r::AbstractUnitRange, name) = namedunitrange(r, name) # Derived interface. `setname` differs from the `AbstractNamedArray` method: it # rebuilds through `named` so the result stays a named unit range, not a named -# array. The rest of the named interface (`==`, `hash`, `isnamed`, `unnamedtype`, -# `nametype`, `uniquename`, `show`, `isempty`) is inherited from `AbstractNamedArray`. +# array. The rest of the named interface (`isnamed`, `unnamedtype`, `nametype`, +# `uniquename`, `show`, `isempty`) is inherited from `AbstractNamedArray`; `==`, +# `isequal`, and `hash` are overridden just below. # TODO: Use `Accessors.@set`? setname(r::NamedUnitRange, name) = named(unnamed(r), name) +# Equality and hashing answer identity ("is this the same leg?"), keyed on the name plus the +# axis's ungraded extent (via `TensorAlgebra.ungrade`). Conjugation preserves the name and the +# ungraded extent while flipping arrows and charge labels, so an index compares equal to its +# dual and stock `Base` set-ops / `Dict` / `Set` treat the two as the same leg. `hash` is keyed +# identically, and `isequal` delegates to `==` (the Base default, valid since `==` returns a +# `Bool`), which also overrides the elementwise `AbstractArray` `isequal` that throws on a +# space-backed axis. +function Base.:(==)(r1::NamedUnitRange, r2::NamedUnitRange) + return name(r1) == name(r2) && ungrade(unnamed(r1)) == ungrade(unnamed(r2)) +end +Base.isequal(r1::NamedUnitRange, r2::NamedUnitRange) = r1 == r2 +Base.hash(r::NamedUnitRange, h::UInt) = hash(ungrade(unnamed(r)), hash(name(r), h)) + # Forward `conj` to the underlying range so graded axes flip their sector # arrows. The `Base.conj(::AbstractArray{<:Real}) = x` fallback would # otherwise short-circuit before the inner range is touched. diff --git a/src/tensoralgebra.jl b/src/tensoralgebra.jl index d282fd2..0a6b9c3 100644 --- a/src/tensoralgebra.jl +++ b/src/tensoralgebra.jl @@ -637,9 +637,9 @@ end Trace of `a` viewed as a map, pairing each `codomain` index with the `domain` index in the same position (matching sizes) and summing the diagonal. `codomain` and `domain` together -must cover all of `a`'s indices, so the result is a scalar. Contracts `a` against an identity -built over the dual axes (`similar` a shell over the conjugated codomain/domain, filled by -`one!`), so it follows `a`'s backend (dense, graded, or `TensorMap`). +must cover all of `a`'s indices, so the result is a scalar. Forwards to `TensorAlgebra.tr` on +the unnamed data, which matricizes `a` into its square matrix and takes the matrix trace, so it +follows `a`'s backend (dense, graded, or `TensorMap`). # Examples @@ -654,9 +654,7 @@ julia> tr(id(Float64, (i, j), (k, l)), (i, j), (k, l)) """ function LA.tr(a::AbstractNamedTensor, codomain, domain) codomain, domain = Tuple(codomain), Tuple(domain) - Id = similar(a, conj.(codomain), conj.(domain)) - TA.one!(unnamed(Id), Val(length(codomain))) - return (a * Id)[] + return TA.tr(unnamed(a), dimnames(a), name.(codomain), name.(domain)) end const MATRIX_FUNCTIONS = [ diff --git a/test/test_basics.jl b/test/test_basics.jl index 030a383..ce13147 100644 --- a/test/test_basics.jl +++ b/test/test_basics.jl @@ -1,6 +1,8 @@ using ITensorBase: ITensorBase, AbstractNamedTensor, ITensor, Index, IndexName, NamedTensor, - dimnametype, gettag, hastag, id, inds, mapinds, name, named, noprime, plev, prime, - replaceinds, setplev, settag, sim, tags, uniquename, unname, unnamed, unsettag, uuid + commonind, commoninds, dimnametype, gettag, hascommoninds, hastag, id, inds, mapinds, + name, named, noncommoninds, noprime, plev, prime, replaceinds, setplev, settag, sim, + tags, trycommonind, trynoncommonind, unioninds, uniqueind, uniqueinds, uniquename, + unname, unnamed, unsettag, uuid using Test: @test, @test_broken, @test_throws, @testset using UUIDs: UUID diff --git a/test/test_tensoralgebra.jl b/test/test_tensoralgebra.jl index e423124..3c6ea30 100644 --- a/test/test_tensoralgebra.jl +++ b/test/test_tensoralgebra.jl @@ -166,7 +166,7 @@ using Test: @test, @test_broken, @testset end end @testset "tr" begin - i, j = namedoneto.((2, 3), ("i", "j")) + i, j = Index.((2, 3)) ip, jp = prime(i), prime(j) a = randn(elt, i, j, ip, jp) # The trace pairs (i, j) with (ip, jp), matching the dense matrix trace of the diff --git a/test/test_tensorkitext.jl b/test/test_tensorkitext.jl index 6a00c5e..63742eb 100644 --- a/test/test_tensorkitext.jl +++ b/test/test_tensorkitext.jl @@ -36,6 +36,15 @@ using Test: @test, @test_throws, @testset @test unnamed(conj(i)) == dual(Vi) @test name(conj(i)) == name(i) + # Equality is dual-insensitive: an index equals its dual (same name, same ungraded + # extent), hashes match, and a fresh index of the same space is a distinct leg. This is + # what lets `Base` set-ops / `Dict` / `Set` treat an index and its dual as one leg on a + # symmetric backend. + @test conj(i) == i + @test isequal(conj(i), i) + @test hash(conj(i)) == hash(i) + @test Index(Vi) != i + # Cold-start construction wraps a `TensorMap`; size/eltype report dense values. a = randn(rng, elt, i, j) @test unnamed(a) isa AbstractTensorMap From 5f1b38fabf2bb9910bcafa4ae95f769a3c848339 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 13:13:21 -0400 Subject: [PATCH 09/15] Accept any iterable of axes in the map-shaped similar The map-shaped `similar(a, [elt,] codomain, domain)` now takes its axis lists as any iterable, so a vector or a tuple of named axes both work. The body is already generic over the collection (broadcast plus splat, and `TensorAlgebra.similar_map` accepts either), so the previous `Tuple{Vararg{NamedUnitRange}}` restriction only forced callers to pre-wrap their axes. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/abstractnamedtensor.jl | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index c5aae83..f29f5b9 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -467,18 +467,13 @@ end # backend, with the domain axes dualized in storage (as `TensorAlgebra.similar_map` does). # Unlike the single-axis-tuple forms above (all-codomain), this carries a nontrivial domain, # so a graded / `TensorMap` backend allocates the correct `codomain ← domain` block structure. -function Base.similar( - a::AbstractNamedTensor, - codomain::Tuple{Vararg{NamedUnitRange}}, - domain::Tuple{Vararg{NamedUnitRange}} - ) +# +# `codomain`/`domain` are any iterable of named axes (tuple or vector); the body is generic +# over the collection, so no conversion is needed. +function Base.similar(a::AbstractNamedTensor, codomain, domain) return similar(a, eltype(a), codomain, domain) end -function Base.similar( - a::AbstractNamedTensor, elt::Type, - codomain::Tuple{Vararg{NamedUnitRange}}, - domain::Tuple{Vararg{NamedUnitRange}} - ) +function Base.similar(a::AbstractNamedTensor, elt::Type, codomain, domain) raw = TensorAlgebra.similar_map(unnamed(a), elt, unnamed.(codomain), unnamed.(domain)) return nameddims(raw, (name.(codomain)..., name.(domain)...)) end From 30da49bab8c833a994a031db2d255fbe6cc4c31b Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 15:17:17 -0400 Subject: [PATCH 10/15] Add datatype for AbstractNamedTensor Adds a `datatype` method for `AbstractNamedTensor` that returns the underlying storage array type by delegating to `TensorAlgebra.datatype` through `unnamed`, so downstream code can get an ITensor's storage type through the shared `TensorAlgebra.datatype` interface rather than a bespoke accessor. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/abstractnamedtensor.jl | 4 ++++ test/test_nameddims_basics.jl | 2 ++ 2 files changed, 6 insertions(+) diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index f29f5b9..0980d50 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -316,6 +316,10 @@ Base.ndims(a::AbstractNamedTensor) = TensorAlgebra.ndims(unnamed(a)) # Circumvent issue when eltype isn't known at compile time. Base.eltype(a::AbstractNamedTensor) = eltype(unnamed(a)) +# The storage array type, reached through `unnamed` (an ITensor's backing is an instance +# operation) and delegated to `TensorAlgebra.datatype` so any further array wrappers unwrap. +TensorAlgebra.datatype(a::AbstractNamedTensor) = TensorAlgebra.datatype(unnamed(a)) + # In-place zero of an NamedTensor, delegating to its unnamed parent array. TensorAlgebra.zero!(a::AbstractNamedTensor) = (zero!(unnamed(a)); a) diff --git a/test/test_nameddims_basics.jl b/test/test_nameddims_basics.jl index f442c3c..93b8030 100644 --- a/test/test_nameddims_basics.jl +++ b/test/test_nameddims_basics.jl @@ -4,6 +4,7 @@ using ITensorBase: @names, AbstractNamedTensor, Name, NameMismatch, NamedDimsCar dimnametype, dims, inds, isnamed, mapinds, name, named, nameddims, namedoneto, product, replacedimnames, replaceinds, setdimnames, unname, unnamed, unnamedtype using LinearAlgebra: LinearAlgebra +using TensorAlgebra: datatype using Test: @test, @test_throws, @testset using VectorInterface: scalartype @@ -83,6 +84,7 @@ end na = nameddims(a, ("i", "j")) @test eltype(na) ≡ elt @test scalartype(na) ≡ elt + @test datatype(na) ≡ typeof(a) a′ = Array(na) @test eltype(a′) ≡ elt @test a′ isa Matrix{elt} From 8a896d2ca717e3d3e82de63f1494929f11cbce62 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 15:57:20 -0400 Subject: [PATCH 11/15] Drop datatype method, rely on TensorAlgebra generic Remove the `TensorAlgebra.datatype(a::AbstractNamedTensor)` method. The generic in TensorAlgebra now reaches the storage through `parent`, and `Base.parent(::NamedTensor)` already returns the unnamed backing, so named tensors resolve through it without a dedicated method. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/abstractnamedtensor.jl | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index 0980d50..f29f5b9 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -316,10 +316,6 @@ Base.ndims(a::AbstractNamedTensor) = TensorAlgebra.ndims(unnamed(a)) # Circumvent issue when eltype isn't known at compile time. Base.eltype(a::AbstractNamedTensor) = eltype(unnamed(a)) -# The storage array type, reached through `unnamed` (an ITensor's backing is an instance -# operation) and delegated to `TensorAlgebra.datatype` so any further array wrappers unwrap. -TensorAlgebra.datatype(a::AbstractNamedTensor) = TensorAlgebra.datatype(unnamed(a)) - # In-place zero of an NamedTensor, delegating to its unnamed parent array. TensorAlgebra.zero!(a::AbstractNamedTensor) = (zero!(unnamed(a)); a) From 70e10cb28a8702ce1b955fc17d8653b18f2816ca Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 16:58:23 -0400 Subject: [PATCH 12/15] Add doctests and polish the index and identity docstrings Give the name-based index-set functions, `replaceinds`, and `mapinds` runnable doctests, and simplify the `replaceinds`/`mapinds` docstrings to describe the relabel directly. Credit the TensorKit functions that `id` and `one` are modeled on, and simplify the `id`/`one`/`tr` examples to use `Index` and demonstrate the identity through `tr`. Fix the `tr` doctest so it brings `tr` into scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/abstractnamedtensor.jl | 139 ++++++++++++++++++++++++++++++++++--- src/tensoralgebra.jl | 42 ++++++----- 2 files changed, 153 insertions(+), 28 deletions(-) diff --git a/src/abstractnamedtensor.jl b/src/abstractnamedtensor.jl index f29f5b9..a573547 100644 --- a/src/abstractnamedtensor.jl +++ b/src/abstractnamedtensor.jl @@ -525,12 +525,20 @@ mapdimnames(f, a::AbstractNamedTensor) = replacedimnames(f, a) replaceinds(a::AbstractNamedTensor, replacements::Pair...) replaceinds(f, a::AbstractNamedTensor) -Return a tensor with the same data as `a` but with its indices relabeled. Unlike -[`replacedimnames`](@ref), whose function `f` receives a dimension name, `replaceinds` works -at the index level: the pair form takes `old => new` index pairs, and the function form -relabels each index `i` using `f(i)`. Either way this is a name-only relabel, taking just the -name of the replacement and leaving the underlying space untouched (replacing the space -instead would scalar-index a graded axis). Pairs whose index is absent are ignored. +Return a tensor with the same data as `a`, with its indices relabeled to the ones specified. +The pair form takes `old => new` index pairs, and the function form relabels each index `i` +using `f(i)`. + +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> t = randn(i, j); + +julia> inds(replaceinds(t, i => k)) == [k, j] +true +``` See also [`mapinds`](@ref), [`replacedimnames`](@ref). """ @@ -542,9 +550,20 @@ replaceinds(f, a::AbstractNamedTensor) = replaceinds(a, map(i -> i => f(i), inds """ mapinds(f, a::AbstractNamedTensor) -Return a tensor with the same data as `a` but with each index `i` relabeled using `f(i)`, a -name-only relabel that leaves the underlying space untouched. This is the whole-tensor -index-map primitive behind [`prime`](@ref), [`noprime`](@ref), and [`sim`](@ref). +Return a tensor with the same data as `a`, with each index `i` relabeled using `f(i)`. This is +the function form of [`replaceinds`](@ref), taking a function input instead of `old => new` +pairs. + +# Examples + +```jldoctest +julia> i, j = Index.((2, 3)); + +julia> t = randn(i, j); + +julia> inds(mapinds(prime, t)) == [prime(i), prime(j)] +true +``` See also [`replaceinds`](@ref). """ @@ -587,6 +606,17 @@ nameissetequal(a, b) = smallissetequal(a, b; by = name) The indices shared by name between `a` and `b`, as a `Vector` in the order they appear in `a`. +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> commoninds(a, b) == [j] +true +``` + See also [`commonind`](@ref), [`uniqueinds`](@ref), [`hascommoninds`](@ref). """ commoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) = nameintersect(inds(a), inds(b)) @@ -597,6 +627,17 @@ commoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) = nameintersect(inds( The indices of `a` that do not appear by name in `b`, as a `Vector` in the order they appear in `a`. +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> uniqueinds(a, b) == [i] +true +``` + See also [`uniqueind`](@ref), [`commoninds`](@ref), [`noncommoninds`](@ref). """ uniqueinds(a::AbstractNamedTensor, b::AbstractNamedTensor) = namesetdiff(inds(a), inds(b)) @@ -607,6 +648,17 @@ uniqueinds(a::AbstractNamedTensor, b::AbstractNamedTensor) = namesetdiff(inds(a) The union by name of the indices of `a` and `b`, as a `Vector`: the indices of `a` followed by the indices of `b` not already present in `a`. +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> unioninds(a, b) == [i, j, k] +true +``` + See also [`commoninds`](@ref), [`noncommoninds`](@ref). """ unioninds(a::AbstractNamedTensor, b::AbstractNamedTensor) = nameunion(inds(a), inds(b)) @@ -617,6 +669,17 @@ unioninds(a::AbstractNamedTensor, b::AbstractNamedTensor) = nameunion(inds(a), i The indices not shared by name between `a` and `b` (the symmetric difference), as a `Vector`: the indices unique to `a` followed by those unique to `b`. +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> noncommoninds(a, b) == [i, k] +true +``` + See also [`uniqueinds`](@ref), [`commoninds`](@ref). """ function noncommoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) @@ -628,6 +691,17 @@ end Whether `a` and `b` share any index by name. +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> hascommoninds(a, b) +true +``` + See also [`commoninds`](@ref). """ function hascommoninds(a::AbstractNamedTensor, b::AbstractNamedTensor) @@ -640,6 +714,17 @@ end The single index shared by name between `a` and `b`. Errors unless there is exactly one shared index. Use [`trycommonind`](@ref) to get `nothing` instead of an error. +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> commonind(a, b) == j +true +``` + See also [`commoninds`](@ref), [`uniqueind`](@ref). """ commonind(a::AbstractNamedTensor, b::AbstractNamedTensor) = only(commoninds(a, b)) @@ -650,6 +735,17 @@ commonind(a::AbstractNamedTensor, b::AbstractNamedTensor) = only(commoninds(a, b The single index of `a` that does not appear by name in `b`. Errors unless there is exactly one such index. Use [`trynoncommonind`](@ref) to get `nothing` instead of an error. +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> uniqueind(a, b) == i +true +``` + See also [`uniqueinds`](@ref), [`commonind`](@ref). """ uniqueind(a::AbstractNamedTensor, b::AbstractNamedTensor) = only(uniqueinds(a, b)) @@ -659,6 +755,20 @@ uniqueind(a::AbstractNamedTensor, b::AbstractNamedTensor) = only(uniqueinds(a, b The single index shared by name between `a` and `b`, or `nothing` if they share no index or more than one. The non-erroring counterpart of [`commonind`](@ref). + +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> trycommonind(a, b) == j +true + +julia> isnothing(trycommonind(a, randn(k))) +true +``` """ function trycommonind(a::AbstractNamedTensor, b::AbstractNamedTensor) cs = commoninds(a, b) @@ -670,6 +780,17 @@ end The single index of `a` that does not appear by name in `b`, or `nothing` if there is no such index or more than one. The non-erroring counterpart of [`uniqueind`](@ref). + +# Examples + +```jldoctest +julia> i, j, k = Index.((2, 3, 2)); + +julia> a, b = randn(i, j), randn(j, k); + +julia> trynoncommonind(a, b) == i +true +``` """ function trynoncommonind(a::AbstractNamedTensor, b::AbstractNamedTensor) us = uniqueinds(a, b) diff --git a/src/tensoralgebra.jl b/src/tensoralgebra.jl index 0a6b9c3..49e75b2 100644 --- a/src/tensoralgebra.jl +++ b/src/tensoralgebra.jl @@ -568,21 +568,22 @@ The identity acts as the multiplicative identity for `ITensorBase.apply`: it contracts on the domain names and renames the resulting codomain names back to the domain names, leaving the input unchanged. +Note that this is inspired by the tensor map function `TensorKit.one` in +[TensorKit.jl](https://github.com/Jutho/TensorKit.jl). + # Examples ```jldoctest -julia> using ITensorBase: apply, namedoneto, operator - -julia> i, j, k, l = namedoneto.((2, 3, 2, 3), ("i", "j", "k", "l")); +julia> using ITensorBase: Index -julia> a = randn(i, j, k, l); +julia> using LinearAlgebra: tr -julia> Id = operator(one(a, (i, j), (k, l)), ("i", "j"), ("k", "l")); +julia> i, j, k, l = Index.((2, 3, 2, 3)); -julia> v = randn(k, l); +julia> a = randn(i, j, k, l); -julia> apply(Id, v) ≈ v -true +julia> tr(one(a, (i, j), (k, l)), (i, j), (k, l)) +6.0 ``` """ function Base.one( @@ -608,19 +609,20 @@ Unlike [`one`](@ref), which follows a prototype tensor, `id` needs only the indi element type, so it is the right primitive when no prototype is in hand. The index axes select the backend: dense ranges give a dense tensor, graded ranges a block-sparse one. +Note that this is inspired by the tensor map function `TensorKit.id` in +[TensorKit.jl](https://github.com/Jutho/TensorKit.jl). + # Examples ```jldoctest -julia> using ITensorBase: apply, id, namedoneto, operator +julia> using ITensorBase: Index, id -julia> i, j, k, l = namedoneto.((2, 3, 2, 3), ("i", "j", "k", "l")); +julia> using LinearAlgebra: tr -julia> Id = operator(id(Float64, (i, j), (k, l)), ("i", "j"), ("k", "l")); +julia> i, j, k, l = Index.((2, 3, 2, 3)); -julia> v = randn(k, l); - -julia> apply(Id, v) ≈ v -true +julia> tr(id(Float64, (i, j), (k, l)), (i, j), (k, l)) +6.0 ``` See also [`one`](@ref). @@ -644,12 +646,14 @@ follows `a`'s backend (dense, graded, or `TensorMap`). # Examples ```jldoctest -julia> using ITensorBase: id, namedoneto +julia> using ITensorBase: Index -julia> i, j, k, l = namedoneto.((2, 3, 2, 3), ("i", "j", "k", "l")); +julia> using LinearAlgebra: tr -julia> tr(id(Float64, (i, j), (k, l)), (i, j), (k, l)) -6.0 +julia> i, j, k, l = Index.((2, 3, 2, 3)); + +julia> tr(fill(2.0, (i, j, k, l)), (i, j), (k, l)) +12.0 ``` """ function LA.tr(a::AbstractNamedTensor, codomain, domain) From a689053a383293002c6db65b77d53b6e58d07dd5 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 17:51:17 -0400 Subject: [PATCH 13/15] Add auto-naming matricize(a, codomain, domain) for named tensors Adds a positional `matricize` form that fuses the two dimension groups into a matrix and mints fresh unique names for the fused row and column dimensions, next to the pair form that takes explicit output names. Both forms now accept any iterable of dimensions per group, so a `Vector` works without wrapping in `Tuple`. Documents both forms with a shared docstring and doctest, and adds tests covering the positional form and vector groups. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tensoralgebra.jl | 39 ++++++++++++++++++++++++++++++++++---- test/test_tensoralgebra.jl | 9 +++++++++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/tensoralgebra.jl b/src/tensoralgebra.jl index 49e75b2..57a2497 100644 --- a/src/tensoralgebra.jl +++ b/src/tensoralgebra.jl @@ -77,15 +77,46 @@ end # Locate the named-dimension groups `group1`, `group2` within `a`, returning their two # positional index groups. function nameperm(a::AbstractNamedTensor, group1, group2) - return TA.biperm(dimnames(a), name.(group1), name.(group2)) + return TA.biperm(dimnames(a), name.(Tuple(group1)), name.(Tuple(group2))) end -# i, j, k, l = named.((2, 2, 2, 2), ("i", "j", "k", "l")) -# a = randn(i, j, k, l) -# matricize(a, (i, k) => "a", (j, l) => "b") +""" + TensorAlgebra.matricize(a::AbstractNamedTensor, codomain => rowname, domain => colname) + TensorAlgebra.matricize(a::AbstractNamedTensor, codomain, domain) + +Reshape the named tensor `a` into a matrix, fusing the `codomain` dimension group into the +rows and the `domain` group into the columns. `codomain` and `domain` are each any iterable +of dimensions (or dimension names) of `a`, and together they must cover all of `a`'s +dimensions. The pair form labels the two fused dimensions with the given `rowname` and +`colname`; the positional form generates fresh unique names for them. + +# Examples + +```jldoctest +julia> using ITensorBase: Index + +julia> using TensorAlgebra: matricize + +julia> i, j, k, l = Index.((2, 3, 2, 3)); + +julia> a = randn(i, j, k, l); + +julia> size(matricize(a, (i, k), (j, l))) +(4, 9) + +julia> Array(matricize(a, (i, k), (j, l))) == + Array(matricize(a, (i, k) => "rows", (j, l) => "cols")) +true +``` +""" function TA.matricize(a::AbstractNamedTensor, fusions::Vararg{Pair, 2}) return matricize_nameddims(a, fusions...) end +function TA.matricize(a::AbstractNamedTensor, codomain, domain) + row_name = uniquename(dimnametype(a)) + col_name = uniquename(dimnametype(a)) + return TA.matricize(a, codomain => row_name, domain => col_name) +end function matricize_nameddims(na::AbstractNamedTensor, fusions::Vararg{Pair, 2}) group1, group2 = first.(fusions) perm_codomain, perm_domain = nameperm(na, group1, group2) diff --git a/test/test_tensoralgebra.jl b/test/test_tensoralgebra.jl index 3c6ea30..601c076 100644 --- a/test/test_tensoralgebra.jl +++ b/test/test_tensoralgebra.jl @@ -38,6 +38,15 @@ using Test: @test, @test_broken, @testset length(j) * length(l), ) ) + # Positional form auto-generates the two fused dimension names, matching the + # pair form's data. + na_pos = matricize(na, (k, i), (j, l)) + @test ndims(na_pos) == 2 + @test Array(na_pos) == Array(na_fused) + # Groups may be any iterable of dimensions, not only tuples (no `Tuple` wrapping + # needed), in both the pair and positional forms. + @test Array(matricize(na, [k, i] => "a", [j, l] => "b")) == Array(na_fused) + @test Array(matricize(na, [k, i], [j, l])) == Array(na_pos) end @testset "unmatricize" begin a, b = namedoneto.((6, 20), ("a", "b")) From 2863babd57a415fc1e155b4a303641e29622b999 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 18:38:40 -0400 Subject: [PATCH 14/15] Drop the TensorAlgebra [sources] pin, require TensorAlgebra 0.17.1 TensorAlgebra 0.17.1 is registered, so drop the branch `[sources]` pin and set the compat floor to 0.17.1, the version that provides the `data`/`datatype` and `matricize` additions this PR builds on. Co-Authored-By: Claude Opus 4.8 (1M context) --- Project.toml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Project.toml b/Project.toml index 4b40170..f0bded5 100644 --- a/Project.toml +++ b/Project.toml @@ -32,10 +32,6 @@ OMEinsumContractionOrders = "6f22d1fd-8eed-4bb7-9776-e7d684900715" TensorKit = "07d1fe3e-3e46-537d-9eac-e9e13d0d4cec" TensorOperations = "6aa20fa7-93e2-5fca-9bc0-fbd0db3c71a2" -[sources.TensorAlgebra] -rev = "mf/nextgen-followups-round1" -url = "https://github.com/ITensor/TensorAlgebra.jl" - [extensions] ITensorBaseAdaptExt = "Adapt" ITensorBaseMooncakeExt = "Mooncake" @@ -58,7 +54,7 @@ OMEinsumContractionOrders = "1" OrderedCollections = "1.6" Random = "1.10" SimpleTraits = "0.9.4" -TensorAlgebra = "0.17" +TensorAlgebra = "0.17.1" TensorKit = "0.17" TensorOperations = "5.3.1" TermInterface = "2" From eb6d837017ca05666521366d7656281c1d2f4241 Mon Sep 17 00:00:00 2001 From: Matthew Fishman Date: Thu, 9 Jul 2026 19:44:42 -0400 Subject: [PATCH 15/15] Hash NamedUnitRange through hash_named on its ungraded range The dual-insensitive `NamedUnitRange` hash bypassed `hash_named`, dropping the `:NamedArray` typetag and reordering the fields, so a named range no longer hashed equal to a named array of the same values despite comparing `==` to it, breaking Julia's `a == b` implies `hash(a) == hash(b)` contract. Add `ungrade` on `NamedUnitRange` (strip the grading, keep the name) and hash through `hash_named(:NamedArray, ungrade(r), h)`, matching the named-array hash while keeping the dual-insensitivity. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/namedunitrange.jl | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/namedunitrange.jl b/src/namedunitrange.jl index 0659fdb..6b1a744 100644 --- a/src/namedunitrange.jl +++ b/src/namedunitrange.jl @@ -87,15 +87,19 @@ setname(r::NamedUnitRange, name) = named(unnamed(r), name) # Equality and hashing answer identity ("is this the same leg?"), keyed on the name plus the # axis's ungraded extent (via `TensorAlgebra.ungrade`). Conjugation preserves the name and the # ungraded extent while flipping arrows and charge labels, so an index compares equal to its -# dual and stock `Base` set-ops / `Dict` / `Set` treat the two as the same leg. `hash` is keyed -# identically, and `isequal` delegates to `==` (the Base default, valid since `==` returns a -# `Bool`), which also overrides the elementwise `AbstractArray` `isequal` that throws on a -# space-backed axis. +# dual and stock `Base` set-ops / `Dict` / `Set` treat the two as the same leg. `isequal` +# delegates to `==` (the Base default, valid since `==` returns a `Bool`), which also overrides +# the elementwise `AbstractArray` `isequal` that throws on a space-backed axis. function Base.:(==)(r1::NamedUnitRange, r2::NamedUnitRange) return name(r1) == name(r2) && ungrade(unnamed(r1)) == ungrade(unnamed(r2)) end Base.isequal(r1::NamedUnitRange, r2::NamedUnitRange) = r1 == r2 -Base.hash(r::NamedUnitRange, h::UInt) = hash(ungrade(unnamed(r)), hash(name(r), h)) +# `ungrade` strips the grading from the underlying range, keeping the name, so hashing runs +# through the shared `hash_named(:NamedArray, ...)` path on the ungraded range. That keeps `hash` +# consistent with `==` above and with a named array of equal values (Base's `[1, 2, 3] == 1:3` +# and `hash([1, 2, 3]) == hash(1:3)` contract). +TensorAlgebra.ungrade(r::NamedUnitRange) = named(ungrade(unnamed(r)), name(r)) +Base.hash(r::NamedUnitRange, h::UInt) = hash_named(:NamedArray, ungrade(r), h) # Forward `conj` to the underlying range so graded axes flip their sector # arrows. The `Base.conj(::AbstractArray{<:Real}) = x` fallback would