From 3819f7049bd0d8fe363076ab4563178a05d2a301 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 7 Jul 2026 14:38:22 -0400 Subject: [PATCH 01/23] BUG: add algorithm struct and registration Add a BUG (Basis-Update & Galerkin) time-evolution algorithm as a sibling of TDVP/TDVP2: struct + keyword constructor, docstring, module include, export, docs listing, changelog and bibliography entries. timestep! is a stub pending the implementation in the next commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/src/assets/mpskit.bib | 15 ++++++++ docs/src/changelog.md | 2 ++ docs/src/man/algorithms.md | 1 + src/MPSKit.jl | 3 +- src/algorithms/timestep/bug.jl | 62 ++++++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/algorithms/timestep/bug.jl diff --git a/docs/src/assets/mpskit.bib b/docs/src/assets/mpskit.bib index 296f4194b..1c2e16e7a 100644 --- a/docs/src/assets/mpskit.bib +++ b/docs/src/assets/mpskit.bib @@ -29,6 +29,21 @@ @article{capponi2025 abstract = {We investigate the nature of the quantum phase transition in modulated Heisenberg spin chains. In the odd- case, the transition separates a trivial nondegenerate phase to a doubly degenerate gapped chiral symmetry-protected topological (SPT) phase which breaks spontaneously the inversion symmetry. The transition is not an Ising transition associated to the breaking of the inversion symmetry, but is governed by the delocalization of the edge states of the SPT phase. In this respect, a modulated Heisenberg spin chain provides a simple example in one dimension of a non-Landau phase transition which is described by the conformal field theory. We show that the chiral SPT phase exhibits fractionalized spinon excitations, which can be confined by slightly changing the model parameters.} } +@article{ceruti2022, + title = {An Unconventional Robust Integrator for Dynamical Low-Rank Approximation}, + author = {Ceruti, Gianluca and Lubich, Christian}, + year = {2022}, + month = mar, + journal = {BIT Numerical Mathematics}, + volume = {62}, + number = {1}, + pages = {23--44}, + publisher = {Springer}, + doi = {10.1007/s10543-021-00873-0}, + url = {https://doi.org/10.1007/s10543-021-00873-0}, + abstract = {We propose and analyze a numerical integrator for computing the low-rank approximation to solutions of matrix differential equations. The proposed method is based on a variant of the projector-splitting integrator, but here the sub-steps are chosen such that the numerical integrator is robust to the presence of small singular values in the solution.} +} + @article{chepiga2017, title = {Excitation Spectrum and Density Matrix Renormalization Group Iterations}, author = {Chepiga, Natalia and Mila, Fr{\'e}d{\'e}ric}, diff --git a/docs/src/changelog.md b/docs/src/changelog.md index f7cd0741e..7d0fbb154 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -21,6 +21,8 @@ When releasing a new version, move the "Unreleased" changes to a new version sec ### Added +- `BUG` time-evolution algorithm: a Basis-Update & Galerkin integrator for finite MPS + ### Changed - `environments` now follows a single positional contract for every state and operator kind: diff --git a/docs/src/man/algorithms.md b/docs/src/man/algorithms.md index 3cf71d52a..694e46fbb 100644 --- a/docs/src/man/algorithms.md +++ b/docs/src/man/algorithms.md @@ -104,6 +104,7 @@ This procedure is commonly referred to as the [`TDVP`](@ref) algorithm, which ag ```@docs; canonical=false TDVP TDVP2 +BUG ``` ### Time evolution MPO diff --git a/src/MPSKit.jl b/src/MPSKit.jl index b7387f3da..3917bca2b 100644 --- a/src/MPSKit.jl +++ b/src/MPSKit.jl @@ -35,7 +35,7 @@ export VUMPS, VOMPS, DMRG, DMRG2, IDMRG, IDMRG2, GradientGrassmann export excitations export FiniteExcited, QuasiparticleAnsatz, ChepigaAnsatz, ChepigaAnsatz2 export time_evolve, timestep, timestep!, make_time_mpo -export TDVP, TDVP2, WI, WII, TaylorCluster +export TDVP, TDVP2, BUG, WI, WII, TaylorCluster export changebonds, changebonds! export VUMPSSvdCut, OptimalExpand, SvdCut, RandExpand, SketchedExpand export propagator @@ -161,6 +161,7 @@ include("algorithms/changebonds/randexpand.jl") include("algorithms/changebonds/sketchedexpand.jl") include("algorithms/timestep/tdvp.jl") +include("algorithms/timestep/bug.jl") include("algorithms/timestep/taylorcluster.jl") include("algorithms/timestep/wii.jl") include("algorithms/timestep/integrators.jl") diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl new file mode 100644 index 000000000..48039678d --- /dev/null +++ b/src/algorithms/timestep/bug.jl @@ -0,0 +1,62 @@ +""" +$(TYPEDEF) + +Single site MPS time-evolution algorithm based on the Basis-Update & Galerkin (BUG) integrator, +an unconventional robust integrator for dynamical low-rank approximation. + +Unlike [`TDVP`](@ref), BUG advances both the basis-carrying (K-step) and the core (Galerkin C-step) +tensors *forward* in time and never inverts the bond tensor. In particular it has no backward-in-time +substep, which makes it a natural choice for imaginary-time / dissipative evolution where the +backward core step of projector-splitting integrators can become unstable. A truncating `trscheme` +enables later rank-adaptivity. + +!!! note + This is currently a scaffold: the algorithm is registered and dispatches, but `timestep!` is not + yet implemented. + +## Fields + +$(TYPEDFIELDS) + +## References + +* [Ceruti et al. BIT Numer. Math. 62 (2022)](@cite ceruti2022) +""" +struct BUG{A, O, T, S, F} <: Algorithm + "algorithm used in the exponential solvers" + integrator::A + + "tolerance for gauging algorithm" + tolgauge::Float64 + + "maximal amount of iterations for gauging algorithm" + gaugemaxiter::Int + + "algorithm used to re-orthonormalize the basis after each local update" + alg_orth::O + + "truncation scheme used to cut the bond back down for rank-adaptive BUG" + trscheme::T + + "algorithm used for the singular value decomposition" + alg_svd::S + + "callback function applied after each iteration, of signature `finalize(iter, ψ, H, envs) -> ψ, envs`" + finalize::F +end +function BUG(; + integrator = Defaults.alg_expsolve(), tolgauge = Defaults.tolgauge, + gaugemaxiter = Defaults.maxiter, alg_orth = Defaults.alg_orth(), + trscheme = notrunc(), alg_svd = Defaults.alg_svd(), + finalize = Defaults._finalize + ) + return BUG(integrator, tolgauge, gaugemaxiter, alg_orth, trscheme, alg_svd, finalize) +end + +function timestep!( + ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::BUG, + envs = environments(ψ, H, ψ); + imaginary_evolution::Bool = false + ) + throw(ArgumentError("BUG timestep! not yet implemented")) +end From 5c7e267273f8a2d18feb25323a5b8372d2fc1fb5 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 7 Jul 2026 16:09:52 -0400 Subject: [PATCH 02/23] BUG: fixed-rank symmetrized timestep + tests Implement the fixed-rank BUG timestep! for FiniteMPS as a symmetric (2nd-order) composition of two rooted-TTN half-sweeps. Each first-order half-sweep evolves every site tensor exactly once: a K-step (basis update) at the leaf boundary and a forward Galerkin AC-step at every internal node, where the un-evolved old connecting tensor is reprojected onto the already-updated child bases via an accumulated basis-overlap transport tensor. Unlike TDVP there is no backward substep, so imaginary-time evolution stays stable. Adds the copying timestep wrapper and a test file mirroring the TDVP suite (energy conservation, eigenstate phase, TDVP agreement, 2nd-order convergence vs a dense reference, imaginary-time lowering, LazySum/MultipliedOperator). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/algorithms/timestep/bug.jl | 125 +++++++++++++++++++++++++++++++-- test/algorithms/bug.jl | 116 ++++++++++++++++++++++++++++++ 2 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 test/algorithms/bug.jl diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 48039678d..484a281db 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -10,10 +10,6 @@ substep, which makes it a natural choice for imaginary-time / dissipative evolut backward core step of projector-splitting integrators can become unstable. A truncating `trscheme` enables later rank-adaptivity. -!!! note - This is currently a scaffold: the algorithm is registered and dispatches, but `timestep!` is not - yet implemented. - ## Fields $(TYPEDFIELDS) @@ -55,8 +51,125 @@ end function timestep!( ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::BUG, - envs = environments(ψ, H, ψ); + envs::AbstractMPSEnvironments = environments(ψ, H, ψ); imaginary_evolution::Bool = false ) - throw(ArgumentError("BUG timestep! not yet implemented")) + # Symmetric (2nd-order) fixed-rank BUG. Following the rooted TTN recursion (source of + # truth for the MPS caterpillar), one *first-order* BUG sweep evolves every site tensor + # exactly once: a K-step (basis update) at the leaf boundary and a forward Galerkin + # AC-step at every internal node, where the *un-evolved* old connecting tensor is first + # reprojected onto the already-updated child bases. A single symmetrized time step is a + # left→right half-sweep with step `dt/2` composed with its mirror right→left half-sweep, + # which lifts the sequential first-order sweep to second order. + # + # This deliberately does *not* mirror TDVP's per-site "evolve AC / split / evolve the + # split-off core backward" substep, nor a naive per-site "K-step + separate forward + # Galerkin C-step": the latter re-evolves the moving core once per bond and is only + # first-order-consistent to the wrong ODE (the state converges to the wrong direction as + # `dt → 0`). BUG has no backward substep, so imaginary-time evolution stays stable; the + # state is renormalized after each half-sweep when `imaginary_evolution = true`. + if length(ψ) == 1 + Hac = AC_hamiltonian(1, ψ, H, ψ, envs) + ψ.AC[1] = integrate(Hac, ψ.AC[1], t, dt, alg.integrator; imaginary_evolution) + imaginary_evolution && normalize!(ψ) + return ψ, envs + end + + h = dt / 2 + _bug_sweep_right!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right + _bug_sweep_left!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left + return ψ, envs +end + +# Transport of the old→new basis overlap across one bond. +# `transport_right`: given the overlap `T` on bond `i` (mapping the new child bond to the old +# child bond, `old ← new`) and the old/new right-isometries at site `i`, returns the overlap on +# bond `i-1`. `transport_left` is the mirror (`new ← old`) for the left-to-right sweep. +function _bug_transport_right(AR_old, AR_new, T) + @plansor Tnew[-1; -2] := AR_old[-1 1; 2] * T[2; 3] * conj(AR_new[-2 1; 3]) + return Tnew +end +function _bug_transport_left(AL_old, AL_new, T) + @plansor Tnew[-1; -2] := conj(AL_new[1 2; -1]) * T[1; 3] * AL_old[3 2; -2] + return Tnew +end + +# Left→right half-sweep (root = last site, leaf = site 1): center ends at the last site. +function _bug_sweep_right!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) + L = length(ψ) + ψ.AC[1] # gauge center to site 1 (materialize AR[2..L]) + ψ_old = copy(ψ) # frozen "t₀" state for K-step inputs / old bases + envs_old = environments(ψ_old, H, ψ_old) + # `ψ` is mutated in place: its left bases become new (as installed), its right bases stay + # `ψ_old`'s, so `envs` yields new-left / old-right effective Hamiltonians for the Galerkin. + + # leaf (site 1): K-step, keep only the new left isometry + AC1 = integrate(AC_hamiltonian(1, ψ_old, H, ψ_old, envs_old), ψ_old.AC[1], t, τ, alg.integrator; imaginary_evolution) + AL_new, C_new = left_gauge(AC1, alg.alg_orth) + T = isomorphism(scalartype(ψ_old), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) + T = _bug_transport_left(ψ_old.AL[1], AL_new, T) # overlap on bond 1 (new ← old) + ψ.AC[1] = (AL_new, C_new) + + for i in 2:L + Ĉ = _mul_front(T, ψ_old.AC[i]) # reproject old connecting tensor + ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) + if i == L + imaginary_evolution && normalize!(ACi) + ψ.AC[L] = ACi + else + AL_new, C_new = left_gauge(ACi, alg.alg_orth) + T = _bug_transport_left(ψ_old.AL[i], AL_new, T) + ψ.AC[i] = (AL_new, C_new) + end + end + return ψ +end + +# Right→left half-sweep (root = first site, leaf = last site): center ends at the first site. +function _bug_sweep_left!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) + L = length(ψ) + ψ.AC[L] # gauge center to last site (materialize AL[1..L-1]) + ψ_old = copy(ψ) + envs_old = environments(ψ_old, H, ψ_old) + + # leaf (site L): K-step, keep only the new right isometry + ACL = integrate(AC_hamiltonian(L, ψ_old, H, ψ_old, envs_old), ψ_old.AC[L], t, τ, alg.integrator; imaginary_evolution) + C_new, AR_new = right_gauge(ACL, alg.alg_orth) + T = isomorphism(scalartype(ψ_old), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) + T = _bug_transport_right(ψ_old.AR[L], AR_new, T) # overlap on bond L-1 (old ← new) + ψ.AC[L] = (C_new, AR_new) + + for i in (L - 1):-1:1 + Ĉ = ψ_old.AC[i] * T # reproject old connecting tensor + ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) + if i == 1 + imaginary_evolution && normalize!(ACi) + ψ.AC[1] = ACi + else + C_new, AR_new = right_gauge(ACi, alg.alg_orth) + T = _bug_transport_right(ψ_old.AR[i], AR_new, T) + ψ.AC[i] = (C_new, AR_new) + end + end + return ψ +end + +# copying version +function timestep( + ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, + alg::BUG, envs::AbstractMPSEnvironments...; + imaginary_evolution::Bool = false, kwargs... + ) + isreal = (scalartype(ψ) <: Real && !imaginary_evolution) + ψ′ = isreal ? complex(ψ) : copy(ψ) + if length(envs) != 0 && isreal + @warn "Currently cannot reuse real environments for complex evolution" + envs′ = environments(ψ′, H, ψ′) + elseif length(envs) == 1 + envs′ = only(envs) + else + @assert length(envs) == 0 "Invalid signature" + envs′ = environments(ψ′, H, ψ′) + end + return timestep!(ψ′, H, time, timestep, alg, envs′; imaginary_evolution, kwargs...) end diff --git a/test/algorithms/bug.jl b/test/algorithms/bug.jl new file mode 100644 index 000000000..7bfeb1130 --- /dev/null +++ b/test/algorithms/bug.jl @@ -0,0 +1,116 @@ +println(" +----------------------------- +| BUG time-stepping tests | +----------------------------- +") + +using .TestSetup +using Test, TestExtras +using MPSKit +using TensorKit +using TensorKit: ℙ +using LinearAlgebra: dot, norm +using Random + +@testset "BUG time evolution" verbose = true begin + dt = 0.1 + L = 10 + + H = force_planar(heisenberg_XXX(Float64, Trivial; spin = 1 // 2, L)) + ψ = FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + + # 1. energy conservation + eigenstate phase + @testset "energy conservation" begin + ψ1, envs = timestep(ψ₀, H, 0.0, dt, BUG()) + E1 = expectation_value(ψ1, H, envs) + @test E₀ ≈ E1 atol = 1.0e-2 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + end + + # 2. agreement with TDVP over a few real-time steps of a random MPS + @testset "agreement with TDVP" begin + Random.seed!(1234) + ψr = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4)) + δt = 0.01 + ψ_bug, ψ_tdvp = ψr, ψr + for k in 0:4 + ψ_bug, = timestep(ψ_bug, H, k * δt, δt, BUG()) + ψ_tdvp, = timestep(ψ_tdvp, H, k * δt, δt, TDVP()) + end + @test expectation_value(ψ_bug, H) ≈ expectation_value(ψ_tdvp, H) atol = 1.0e-3 + @test abs(dot(ψ_bug, ψ_tdvp)) ≈ 1 atol = 1.0e-3 + end + + # 3. second-order convergence on a small full-rank system (isolates the temporal order) + @testset "second-order convergence" begin + Random.seed!(2) + Lc = 4 + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) + ψ_full = FiniteMPS(rand, ComplexF64, Lc, ℙ^2, ℙ^4) # full-rank: 1,2,4,2,1 + + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψ_full) + ψvec /= norm(ψvec) + + T = 0.5 + dts = [0.1, 0.05, 0.025] + errs = map(dts) do δt + n = round(Int, T / δt) + ref = exp(-im * Hmat * (n * δt)) * ψvec + ψ = copy(ψ_full) + envs = environments(ψ, Hc, ψ) + for k in 0:(n - 1) + timestep!(ψ, Hc, k * δt, δt, BUG(), envs) + end + ψout = convert(TensorMap, ψ) + ψout /= norm(ψout) + return 1 - abs(dot(ψout, ref)) + end + + slopes = [ + (log(errs[i + 1]) - log(errs[i])) / (log(dts[i + 1]) - log(dts[i])) + for i in 1:(length(dts) - 1) + ] + @info "BUG convergence" errs slopes + for s in slopes + @test s ≈ 2 atol = 0.3 + end + end + + # 4. imaginary-time evolution lowers the energy toward the ground state (and, having no + # backward substep, stays norm-preserving/stable) + @testset "imaginary-time lowers energy" begin + Random.seed!(5) + ψi = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4)) + E_start = real(expectation_value(ψi, H)) + E_prev = E_start + for _ in 1:20 + ψi, = timestep(ψi, H, 0.0, 0.1, BUG(); imaginary_evolution = true) + E_now = real(expectation_value(ψi, H)) + @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy + E_prev = E_now + end + @test E_prev < E_start - 1.0 # substantial lowering toward the ground state + @test norm(ψi) ≈ 1 atol = 1.0e-6 # imaginary-time BUG renormalizes each step + end + + # 5. LazySum / MultipliedOperator smoke tests + @testset "LazySum" begin + Hlazy = LazySum([3 * H, 1.55 * H, -0.1 * H]) + ψl, envs = timestep(ψ₀, Hlazy, 0.0, dt, BUG()) + E = expectation_value(ψl, Hlazy, envs) + @test (3 + 1.55 - 0.1) * E₀ ≈ E atol = 1.0e-2 + end + + @testset "TimeDependent LazySum" begin + Ht = MultipliedOperator(H, t -> 4) + MultipliedOperator(H, 1.45) + ψa, envsa = timestep(ψ₀, Ht(1.0), 0.0, dt, BUG()) + Ea = expectation_value(ψa, Ht(1.0), envsa) + + ψt, envst = timestep(ψ₀, Ht, 1.0, dt, BUG()) + Et = expectation_value(ψt, Ht(1.0), envst) + @test Ea ≈ Et atol = 1.0e-8 + end +end From 64eb59397c3afc7ab4f51c035f306de89cb139a1 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 7 Jul 2026 17:13:49 -0400 Subject: [PATCH 03/23] BUG: fixed-rank symmetric-tensor tests Add charge-sector coverage for the fixed-rank BUG using genuine symmetric tensors (U(1) Heisenberg in the Sz=0 and Sz=1 sectors, Z2 transverse-field Ising, SU(2) Heisenberg, and an imaginary-time U(1) run). Each asserts energy conservation, eigenstate phase, and preservation of the graded bond structure across the step. No implementation change was needed: the transport-tensor and adjoint machinery already handles graded/dual spaces correctly (design-doc risks H1/H6/H7 clear). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/algorithms/bug.jl | 93 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/test/algorithms/bug.jl b/test/algorithms/bug.jl index 7bfeb1130..b232c3b6f 100644 --- a/test/algorithms/bug.jl +++ b/test/algorithms/bug.jl @@ -114,3 +114,96 @@ using Random @test Ea ≈ Et atol = 1.0e-8 end end + +# Charge-sector (symmetric-tensor) coverage for the fixed-rank BUG. These use *genuine* +# symmetric tensors (no `force_planar`), exercising the graded-bond paths flagged in the design +# doc's hsector risk register (H1/H6/H7): the transport-tensor seed `isomorphism(V ← V)`, the +# `@plansor` (co)domain/dual conventions in `_bug_transport_*`, and the adjoints carrying sector +# duals. A fixed-rank step must preserve the total charge and the graded structure of every bond. +@testset "BUG symmetric tensors" verbose = true begin + dt = 0.1 + L = 6 + + # 1. U(1)-symmetric Heisenberg, both in the natural total-Sz = 0 sector and in a fixed nonzero + # total-charge (Sz = 1) sector: energy conservation + eigenstate phase + sector preservation. + @testset "U(1) Heisenberg (total Sz = $label)" for (label, right) in + (("0", U1Space(0 => 1)), ("1", U1Space(1 => 1))) + Random.seed!(2718) + H = heisenberg_XXX(ComplexF64, U1Irrep; spin = 1 // 2, L) + maxV = MPSKit.max_virtualspaces(physicalspace(H)) + ψ = FiniteMPS(physicalspace(H), maxV[2:(end - 1)]; right) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + + Vl₀ = left_virtualspace.(Ref(ψ₀), 1:L) + Vr₀ = right_virtualspace.(Ref(ψ₀), 1:L) + + ψ1, envs = timestep(ψ₀, H, 0.0, dt, BUG()) + E1 = expectation_value(ψ1, H, envs) + + @test E₀ ≈ E1 atol = 1.0e-2 + @test imag(E1) ≈ 0 atol = 1.0e-8 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + # the fixed-rank step preserves the graded structure (sector content) of every bond + @test left_virtualspace.(Ref(ψ1), 1:L) == Vl₀ + @test right_virtualspace.(Ref(ψ1), 1:L) == Vr₀ + end + + # 2. A second symmetry group. Z2 (transverse-field Ising) and SU2 (Heisenberg) both stress the + # graded transport tensor; same assertions (energy conservation + eigenstate phase). + @testset "Z2 transverse-field Ising" begin + Random.seed!(161803) + H = transverse_field_ising(ComplexF64, Z2Irrep; g = 1.0, L) + ψ = FiniteMPS(physicalspace(H), Z2Space(0 => 4, 1 => 4)) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + Vl₀ = left_virtualspace.(Ref(ψ₀), 1:L) + + ψ1, envs = timestep(ψ₀, H, 0.0, dt, BUG()) + E1 = expectation_value(ψ1, H, envs) + @test E₀ ≈ E1 atol = 1.0e-2 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + @test left_virtualspace.(Ref(ψ1), 1:L) == Vl₀ + end + + @testset "SU(2) Heisenberg" begin + Random.seed!(577215) + H = heisenberg_XXX(ComplexF64, SU2Irrep; spin = 1 // 2, L) + # SU(2) spin-1/2 bonds alternate between integer / half-integer spins, so use the + # model's own full-rank virtual spaces rather than a hand-picked (integer-only) space. + maxV = MPSKit.max_virtualspaces(physicalspace(H)) + ψ = FiniteMPS(physicalspace(H), maxV[2:(end - 1)]) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + Vl₀ = left_virtualspace.(Ref(ψ₀), 1:L) + + ψ1, envs = timestep(ψ₀, H, 0.0, dt, BUG()) + E1 = expectation_value(ψ1, H, envs) + @test E₀ ≈ E1 atol = 1.0e-2 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + @test left_virtualspace.(Ref(ψ1), 1:L) == Vl₀ + end + + # 3. Imaginary-time symmetric evolution lowers the energy while preserving the sector + norm. + @testset "imaginary-time (U(1))" begin + Random.seed!(141421) + H = heisenberg_XXX(ComplexF64, U1Irrep; spin = 1 // 2, L) + maxV = MPSKit.max_virtualspaces(physicalspace(H)) + ψi = FiniteMPS(physicalspace(H), maxV[2:(end - 1)]) + Vl₀ = left_virtualspace.(Ref(ψi), 1:L) + Vr₀ = right_virtualspace.(Ref(ψi), 1:L) + E_start = real(expectation_value(ψi, H)) + E_prev = E_start + for _ in 1:15 + ψi, = timestep(ψi, H, 0.0, 0.1, BUG(); imaginary_evolution = true) + E_now = real(expectation_value(ψi, H)) + @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy + E_prev = E_now + end + @test E_prev < E_start - 0.5 # substantial lowering toward the ground state + @test norm(ψi) ≈ 1 atol = 1.0e-6 # imaginary-time BUG renormalizes each step + # the sector content of every bond is preserved throughout the imaginary-time sweep + @test left_virtualspace.(Ref(ψi), 1:L) == Vl₀ + @test right_virtualspace.(Ref(ψi), 1:L) == Vr₀ + end +end From b58fe39bad6356d1e5fc890c4f784e5d108c2282 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 7 Jul 2026 17:32:39 -0400 Subject: [PATCH 04/23] BUG: basis-augmentation helper Add _bug_augment_left/_bug_augment_right: state-preserving basis augmentation for rank-adaptive BUG. Given the old isometry U0 and the evolved single-site candidate K1, append the component of K1 orthogonal to U0 (via left_null/right_null! + catdomain/catcodomain, mirroring OptimalExpand's changebond!), keeping the old basis as the leading per-sector block so the reprojection stays exact. Returns the augmented isometry and the overlap M = U^* U0. Not yet wired into timestep!; unit tests cover both sweep directions, trivial and U(1) tensors, the old-first invariant per sector, range capture, rank growth, and a genuinely-new-sector case. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/algorithms/timestep/bug.jl | 53 +++++++++++++ test/algorithms/bug_augment.jl | 134 +++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 test/algorithms/bug_augment.jl diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 484a281db..a472ccefd 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -94,6 +94,59 @@ function _bug_transport_left(AL_old, AL_new, T) return Tnew end +# Basis augmentation (Stage 2 building block; NOT yet wired into `timestep!`). +# +# `_bug_augment_left` augments the RIGHT virtual bond of a site for the left→right sweep. +# Given the OLD left-isometry `U₀` (an MPS tensor `Vl ⊗ P ← Vr₀`, i.e. `AL_old`) and the evolved +# single-site candidate `K₁` (same leg structure `Vl ⊗ P ← Vr_K`, the K-step / Galerkin output), +# it builds an augmented left-isometry `Û` (`Vl ⊗ P ← V̂`) whose column space contains both +# `range(U₀)` and `range(K₁)`, keeping the OLD basis as the leading per-sector block +# (`V̂ = Vr₀ ⊕ Vr_new`). This is the "old-basis-first" augmentation `[U₀ │ Ũ₁]` of the +# rank-adaptive BUG papers, which makes the reprojection `Ŝ₀ = Û* Y₀ = Y₀` exact. +# +# The appended directions come from the *single-site* candidate `K₁` (not a two-site +# `AC2_projection` as in `OptimalExpand`): take the component of `K₁` in the orthogonal complement +# of `U₀` (`left_null`), orthonormalize its column space, and `catdomain` it after `U₀`. This does +# NOT truncate — the appended block has the full rank of that complement (so `dim(V̂) ≤ 2·dim(Vr₀)`). +# +# Returns `(Û, M)`: +# * `Û` — the augmented left-isometry (`Û' Û = 𝟙`, `Vl ⊗ P ← V̂`), +# * `M = Û' * U₀` — the old bond's coordinates in the augmented basis (`V̂ ← Vr₀`), equal to +# `[𝟙; 0]` per sector, ready to embed the old transport/core into the enlarged bond. +function _bug_augment_left(U₀, K₁, alg_orth = Defaults.alg_orth()) + N = left_null(U₀) # Vl⊗P ← Vc, orthonormal complement of range(U₀) + g = N' * K₁ # Vc ← Vr_K, the part of K₁ orthogonal to U₀ + Q, _ = left_orth(g; alg = alg_orth) # Vc ← Vr_new, orthonormal new directions + Ũ₁ = N * Q # Vl⊗P ← Vr_new + Û = catdomain(U₀, Ũ₁) # Vl⊗P ← (Vr₀ ⊕ Vr_new), old-first + M = Û' * U₀ # V̂ ← Vr₀, = [𝟙; 0] per sector + return Û, M +end + +# `_bug_augment_right` is the mirror for the right→left sweep: it augments the LEFT virtual bond, +# working on the `_transpose_tail` form (`Vl ← P ⊗ Vr`, in which a right-isometry has orthonormal +# rows) and using `right_null!`/`catcodomain`, exactly as `changebond!(:left)` and `right_gauge` do. +# Given the OLD right-isometry `U₀` (`Vl₀ ⊗ P ← Vr`, i.e. `AR_old`) and the evolved candidate `K₁` +# (`Vl_K ⊗ P ← Vr`), it returns an augmented right-isometry `Û` (`V̂ ⊗ P ← Vr`) whose left bond is +# `V̂ = Vl₀ ⊕ Vl_new` (old-first) with row space containing both `U₀` and `K₁`. +# +# Returns `(Û, M)`: +# * `Û` — the augmented right-isometry (`Û` is right-canonical, its tail satisfies `û û' = 𝟙`), +# * `M = û * u₀'` (with `û = _transpose_tail(Û)`, `u₀ = _transpose_tail(U₀)`) — the old bond's +# coordinates in the augmented left bond (`V̂ ← Vl₀`), equal to `[𝟙; 0]` per sector. +function _bug_augment_right(U₀, K₁, alg_orth = Defaults.alg_orth()) + u₀ = _transpose_tail(U₀) # Vl₀ ← P⊗Vr, right-isometric (u₀ u₀' = 𝟙) + k₁ = _transpose_tail(K₁) # Vl_K ← P⊗Vr + N = right_null!(_transpose_tail(U₀; copy = true)) # Vc ← P⊗Vr, complement of U₀'s row space + g = k₁ * N' # Vl_K ← Vc, the part of K₁ orthogonal to U₀ + _, Q = right_orth(g; alg = alg_orth) # Q: Vl_new ← Vc, orthonormal new directions + Ũ₁ = Q * N # Vl_new ← P⊗Vr + û = catcodomain(u₀, Ũ₁) # (Vl₀ ⊕ Vl_new) ← P⊗Vr, old-first + Û = _transpose_front(û) # V̂ ⊗ P ← Vr + M = û * u₀' # V̂ ← Vl₀, = [𝟙; 0] per sector + return Û, M +end + # Left→right half-sweep (root = last site, leaf = site 1): center ends at the last site. function _bug_sweep_right!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) L = length(ψ) diff --git a/test/algorithms/bug_augment.jl b/test/algorithms/bug_augment.jl new file mode 100644 index 000000000..6e93917cd --- /dev/null +++ b/test/algorithms/bug_augment.jl @@ -0,0 +1,134 @@ +println(" +------------------------------------ +| BUG basis-augmentation tests | +------------------------------------ +") + +using .TestSetup +using Test, TestExtras +using MPSKit +using MPSKit: _bug_augment_left, _bug_augment_right, _transpose_tail, _transpose_front, + left_orth, right_gauge +using TensorKit +using TensorKit: ℙ +using LinearAlgebra: I, norm +using Random + +# The augment helpers keep the OLD isometry `U₀` as the leading per-sector block and append the +# component of the evolved candidate `K₁` that is orthogonal to it (no truncation). The four core +# properties (checked below for both sweep directions, trivial + U(1)): +# 1. isometry `Û' Û ≈ 𝟙` +# 2. old-first, per sector `M = Û' U₀ ≈ [𝟙; 0]` block-by-block +# 3. range ⊇ old, candidate `Û (Û' U₀) ≈ U₀` and `Û (Û' K₁) ≈ K₁` +# 4. rank growth `dim(Vr₀) < dim(V̂) ≤ 2·dim(Vr₀)` + +# Assert the per-sector "old-first" invariant on the overlap `M` (codomain = augmented bond, +# domain = old bond `V_old`): every sector block must be `[𝟙; 0]`, i.e. the leading +# `dim(V_old, c)` rows are the identity and the rest vanish. Iterating `blocks(M)` visits exactly +# the sectors common to `M`'s (co)domain, so purely-new sectors (absent from `V_old`) are correctly +# skipped here. +function _check_old_first(M, V_old; tol = 1.0e-10) + for (c, b) in blocks(M) + r0 = dim(V_old, c) + @test b[1:r0, :] ≈ I + if r0 < size(b, 1) + @test norm(b[(r0 + 1):end, :]) < tol + end + end + return nothing +end + +function check_augment_left(U₀, K₁; tol = 1.0e-10) + Û, M = _bug_augment_left(U₀, K₁) + # 1. isometry + @test Û' * Û ≈ one(Û' * Û) + # 2. old-first, per sector (M : V̂ ← Vr₀) + _check_old_first(M, domain(U₀)[1]; tol) + # 3. range captures both the old basis and the evolved candidate + @test Û * (Û' * U₀) ≈ U₀ + @test Û * (Û' * K₁) ≈ K₁ + @test Û * M ≈ U₀ # consistency of the returned overlap + # 4. rank growth: strictly bigger than the old bond, at most doubled + r = dim(domain(U₀)) + @test r < dim(domain(Û)) ≤ 2r + return Û, M +end + +function check_augment_right(U₀, K₁; tol = 1.0e-10) + Û, M = _bug_augment_right(U₀, K₁) + ût = _transpose_tail(Û) # V̂ ← P ⊗ Vr, right-isometric (row space) + u0t = _transpose_tail(U₀) + k1t = _transpose_tail(K₁) + # 1. isometry (right-canonical ⇒ tail has orthonormal rows) + @test ût * ût' ≈ one(ût * ût') + # 2. old-first, per sector (M : V̂ ← Vl₀) + _check_old_first(M, codomain(u0t)[1]; tol) + # 3. row space captures both the old basis and the evolved candidate + @test (u0t * ût') * ût ≈ u0t + @test (k1t * ût') * ût ≈ k1t + @test M' * ût ≈ u0t # consistency of the returned overlap + # 4. rank growth on the left bond + r = dim(codomain(u0t)) + @test r < dim(codomain(ût)) ≤ 2r + return Û, M +end + +@testset "BUG basis augmentation" verbose = true begin + # ------------------------------------------------------------------------------------------- + # Trivial (dense) tensors, ComplexF64 to exercise the adjoints. + # ------------------------------------------------------------------------------------------- + @testset "trivial tensors" begin + Random.seed!(20260707) + Vl = ℂ^2 + P = ℂ^3 + Vr = ℂ^2 + + # left→right: augment the RIGHT bond (domain of a left-isometry) + U₀_L, _ = left_orth(randn(ComplexF64, Vl ⊗ P ← Vr)) # Vl⊗P ← Vr, left-isometric + K₁_L = randn(ComplexF64, Vl ⊗ P ← Vr) # generic evolved candidate + Û_L, _ = check_augment_left(U₀_L, K₁_L) + + # right→left: augment the LEFT bond (codomain of a right-isometry) + _, U₀_R = right_gauge(randn(ComplexF64, Vl ⊗ P ← Vr)) # V ⊗ P ← Vr, right-isometric + K₁_R = randn(ComplexF64, Vl ⊗ P ← Vr) + Û_R, _ = check_augment_right(U₀_R, K₁_R) + end + + # ------------------------------------------------------------------------------------------- + # U(1)-symmetric tensors: per-sector direct sums, old-first sector-by-sector, and a genuinely + # new sector introduced by the candidate on the left augment. + # ------------------------------------------------------------------------------------------- + @testset "U(1) symmetric tensors" begin + Random.seed!(31415926) + + # left→right, WITH a new sector: `Vr₀` deliberately omits sector 0, which `Vl ⊗ P` + # contains and the candidate `K₁` populates ⇒ augmentation must add it to `V̂`. + Vl = U1Space(0 => 1, 1 => 1) + P = U1Space(0 => 1, 1 => 1) # Vl⊗P : 0=>1, 1=>2, 2=>1 + Vr0 = U1Space(1 => 1, 2 => 1) # omits sector 0 + Vr0K = U1Space(0 => 1, 1 => 1, 2 => 1) # candidate populates sector 0 + + U₀_L, _ = left_orth(randn(ComplexF64, Vl ⊗ P ← Vr0)) + @test domain(U₀_L)[1] == Vr0 # left_orth kept the full old bond + K₁_L = randn(ComplexF64, Vl ⊗ P ← Vr0K) + Û_L, _ = check_augment_left(U₀_L, K₁_L) + + # the augmented bond is a per-sector direct sum that gained sector 0 + V̂_L = domain(Û_L)[1] + @test !(U1Irrep(0) in sectors(Vr0)) + @test U1Irrep(0) in sectors(V̂_L) + # every old sector survives with at least its old multiplicity (old-first ⇒ nothing dropped) + for c in sectors(Vr0) + @test dim(V̂_L, c) ≥ dim(Vr0, c) + end + + # right→left augment on a compatible symmetric configuration + Plr = U1Space(0 => 1, 1 => 1) + Vrr = U1Space(0 => 1, 1 => 1) + Vl0 = U1Space(0 => 1, 1 => 1) + _, U₀_R = right_gauge(randn(ComplexF64, Vl0 ⊗ Plr ← Vrr)) + Vl_K = U1Space(0 => 1, 1 => 1) + K₁_R = randn(ComplexF64, Vl_K ⊗ Plr ← Vrr) + Û_R, _ = check_augment_right(U₀_R, K₁_R) + end +end From 27720a1be5e18025105a3952d854e90e8dca2251 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 7 Jul 2026 18:43:34 -0400 Subject: [PATCH 05/23] BUG: rank-adaptive timestep + tests Wire rank-adaptivity into the BUG timestep!, gated on a truncating trscheme (the default notrunc() keeps the fixed-rank path byte-for-byte). Each adaptive half-sweep augments every internal bond with the new directions found by the evolved connecting tensor (old-basis-first, up to 2r) via _bug_augment_*, keeping the augmented basis so the next node's Galerkin fills the new directions; an optimal SvdCut compression after each half-sweep truncates the bonds back to the trscheme tolerance. In-place truncation of the augmented core cannot grow the bond (rank <= r), so truncation is deferred to the post-sweep compression. Environments recompute lazily. Tests: bond growth (tight vs loose tolerance), accuracy vs a dense reference improving monotonically as the tolerance shrinks, CBE-style tracking of a TDVP2 reference where fixed-rank BUG stays stuck, imaginary-time ground-state growth, and energy/norm behaviour under truncation. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/algorithms/timestep/bug.jl | 127 +++++++++++++++++++++++++++++- test/algorithms/bug.jl | 136 +++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 4 deletions(-) diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index a472ccefd..50af2bc2e 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -7,8 +7,19 @@ an unconventional robust integrator for dynamical low-rank approximation. Unlike [`TDVP`](@ref), BUG advances both the basis-carrying (K-step) and the core (Galerkin C-step) tensors *forward* in time and never inverts the bond tensor. In particular it has no backward-in-time substep, which makes it a natural choice for imaginary-time / dissipative evolution where the -backward core step of projector-splitting integrators can become unstable. A truncating `trscheme` -enables later rank-adaptivity. +backward core step of projector-splitting integrators can become unstable. + +Passing a truncating `trscheme` (anything other than the default `notrunc()`) switches on +**rank-adaptivity**: each half-sweep augments every bond with the new directions discovered by the +evolved connecting tensor (keeping the old basis as the leading block, `[U₀ │ K₁]`) and then +truncates the enlarged bonds back down to the tolerance of `trscheme` by an optimal SVD sweep. The +bond dimension therefore grows and shrinks automatically to track the entanglement of the evolving +state. The default `notrunc()` recovers the fixed-rank integrator exactly. + +!!! note + Real-time evolution does not renormalize: neither the augmentation nor the truncation + renormalizes, so the state norm reflects the accumulated truncation error. Imaginary-time + evolution renormalizes after every half-sweep, like a ground-state search. ## Fields @@ -76,11 +87,38 @@ function timestep!( end h = dt / 2 - _bug_sweep_right!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right - _bug_sweep_left!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left + if _bug_truncates(alg) + # Rank-adaptive BUG: each half-sweep *augments* every bond with the new directions + # discovered by the (Galerkin-)evolved connecting tensor, evolving the core on the + # enlarged (old-first) basis, and is followed by an optimal SVD truncation back down to + # the `trscheme` tolerance (Ceruti–Lubich–Walach 2022). Real-time evolution does not + # renormalize, so the truncation error is reflected in the norm; imaginary-time + # evolution renormalizes each half-sweep. + _bug_sweep_right_adaptive!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right + _bug_truncate!(ψ, alg; normalize = imaginary_evolution) + _bug_sweep_left_adaptive!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left + _bug_truncate!(ψ, alg; normalize = imaginary_evolution) + else + _bug_sweep_right!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right + _bug_sweep_left!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left + end return ψ, envs end +# Rank-adaptivity is enabled by any truncating `trscheme` (the default `notrunc()` selects the +# byte-for-byte fixed-rank path). Mirrors the `_truncates` gate used by `TDVP`/`DMRG`. +_bug_truncates(alg::BUG) = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) + +# Truncate every internal bond back down to the `trscheme` tolerance with an optimal +# (canonical-form) SVD sweep. This is the "discard the singular-value tail whose Frobenius norm +# ≤ ϑ" step of the rank-adaptive integrator, applied to the exact augmented-Galerkin state +# produced by a half-sweep. The effective environments passed to the next half-sweep are keyed on +# tensor identity, so they recompute lazily for the changed bonds — no explicit refresh needed. +function _bug_truncate!(ψ, alg::BUG; normalize::Bool = false) + changebonds!(ψ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) + return ψ +end + # Transport of the old→new basis overlap across one bond. # `transport_right`: given the overlap `T` on bond `i` (mapping the new child bond to the old # child bond, `old ← new`) and the old/new right-isometries at site `i`, returns the overlap on @@ -207,6 +245,87 @@ function _bug_sweep_left!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = f return ψ end +# Rank-adaptive half-sweeps +# ------------------------- +# These mirror `_bug_sweep_right!`/`_bug_sweep_left!` exactly, except that at every internal node +# the fixed-rank `left_gauge`/`right_gauge` is replaced by an *augmentation*: the evolved +# connecting tensor `ACᵢ` is used as the candidate `K₁` and its directions orthogonal to the +# (transported) old isometry are appended, keeping the old basis as the leading per-sector block +# (`_bug_augment_left`/`_bug_augment_right`). The augmented isometry `Û` (rank up to 2r) is +# installed as the new left/right basis *without in-sweep truncation*: the enlarged bond is then +# seen by the next node's Galerkin evolution (which fills the new directions), exactly the +# augmented-basis Galerkin of the rank-adaptive matrix/TTN integrator. The bond factor +# `C_new = Û* ACᵢ` reconstructs `ACᵢ` exactly (`range(ACᵢ) ⊆ range(Û)`), so the embedding of the +# half-sweep state is exact; the subsequent `_bug_truncate!` performs the SVD-tail truncation. +# +# NOTE (deviation from the "core = Û* ACᵢ; svd_trunc!" recipe): truncating that core in place +# cannot grow the bond — it is a `(≤2r ← r)` matrix of rank ≤ r, so its SVD keeps ≤ r directions +# and the bond never exceeds the old rank. Growth requires evolving the *next* node on the +# enlarged bond before cutting, which is why truncation is deferred to `_bug_truncate!` (an +# optimal compression of the exact augmented-Galerkin state — equivalent to SVD-truncating the +# augmented core, but globally optimal). +function _bug_sweep_right_adaptive!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) + L = length(ψ) + ψ.AC[1] + ψ_old = copy(ψ) + envs_old = environments(ψ_old, H, ψ_old) + + # leaf (site 1): K-step, then augment the outgoing (right) bond + AC1 = integrate(AC_hamiltonian(1, ψ_old, H, ψ_old, envs_old), ψ_old.AC[1], t, τ, alg.integrator; imaginary_evolution) + Û, _ = _bug_augment_left(ψ_old.AL[1], AC1, alg.alg_orth) # Vl⊗P ← V̂₁ (old-first, up to 2r) + C_new = Û' * AC1 # V̂₁ ← old_bond_1 (exact: Û·C_new = AC1) + T = isomorphism(scalartype(ψ_old), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) + T = _bug_transport_left(ψ_old.AL[1], Û, T) # V̂₁ ← old_bond_1 + ψ.AC[1] = (Û, C_new) + + for i in 2:L + Ĉ = _mul_front(T, ψ_old.AC[i]) # V̂_{i-1} ⊗ P ← old_bond_i + ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) + if i == L + imaginary_evolution && normalize!(ACi) + ψ.AC[L] = ACi + else + U₀ = _mul_front(T, ψ_old.AL[i]) # old isometry in the new left frame + Û, _ = _bug_augment_left(U₀, ACi, alg.alg_orth) # V̂_{i-1} ⊗ P ← V̂_i (grows bond i) + C_new = Û' * ACi # V̂_i ← old_bond_i + T = _bug_transport_left(ψ_old.AL[i], Û, T) # V̂_i ← old_bond_i + ψ.AC[i] = (Û, C_new) + end + end + return ψ +end + +function _bug_sweep_left_adaptive!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) + L = length(ψ) + ψ.AC[L] + ψ_old = copy(ψ) + envs_old = environments(ψ_old, H, ψ_old) + + # leaf (site L): K-step, then augment the outgoing (left) bond + ACL = integrate(AC_hamiltonian(L, ψ_old, H, ψ_old, envs_old), ψ_old.AC[L], t, τ, alg.integrator; imaginary_evolution) + Û, _ = _bug_augment_right(ψ_old.AR[L], ACL, alg.alg_orth) # V̂_{L-1} ⊗ P ← Vr (old-first, up to 2r) + C_new = _transpose_tail(ACL) * _transpose_tail(Û)' # old_bond_{L-1} ← V̂_{L-1} (exact) + T = isomorphism(scalartype(ψ_old), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) + T = _bug_transport_right(ψ_old.AR[L], Û, T) # old_bond_{L-1} ← V̂_{L-1} + ψ.AC[L] = (C_new, Û) + + for i in (L - 1):-1:1 + Ĉ = ψ_old.AC[i] * T # old_bond_{i-1} ⊗ P ← V̂_i + ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) + if i == 1 + imaginary_evolution && normalize!(ACi) + ψ.AC[1] = ACi + else + U₀ = ψ_old.AR[i] * T # old isometry in the new right frame + Û, _ = _bug_augment_right(U₀, ACi, alg.alg_orth) # V̂_{i-1} ⊗ P ← V̂_i (grows bond i-1) + C_new = _transpose_tail(ACi) * _transpose_tail(Û)' # old_bond_{i-1} ← V̂_{i-1} + T = _bug_transport_right(ψ_old.AR[i], Û, T) # old_bond_{i-1} ← V̂_{i-1} + ψ.AC[i] = (C_new, Û) + end + end + return ψ +end + # copying version function timestep( ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, diff --git a/test/algorithms/bug.jl b/test/algorithms/bug.jl index b232c3b6f..60885d52b 100644 --- a/test/algorithms/bug.jl +++ b/test/algorithms/bug.jl @@ -115,6 +115,142 @@ using Random end end +# Rank-adaptive BUG (Stage 2): a truncating `trscheme` enables basis augmentation + truncation, so +# the bond dimension grows and shrinks to track the entanglement of the evolving state. Trivial +# tensors only (symmetry stress is Chunk 2.3). The default `notrunc()` regression is covered by the +# fixed-rank testsets above (they must all still pass unchanged). +@testset "BUG rank-adaptive" verbose = true begin + # 1. bond growth: a tight tolerance grows a low-bond-dim state, a looser tolerance keeps it + # smaller. + @testset "bond growth" begin + Random.seed!(101) + L = 6 + H = force_planar(transverse_field_ising(ComplexF64, Trivial; L)) + ψ₀ = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^2)) # low bond dim (2) + normalize!(ψ₀) + Dstart = maximum(dim(left_virtualspace(ψ₀, k)) for k in 1:L) + + ψtight = ψ₀ + for k in 0:2 + ψtight, = timestep(ψtight, H, k * 0.05, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-10))) + end + Dtight = maximum(dim(left_virtualspace(ψtight, k)) for k in 1:L) + + ψloose = ψ₀ + for k in 0:2 + ψloose, = timestep(ψloose, H, k * 0.05, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-2))) + end + Dloose = maximum(dim(left_virtualspace(ψloose, k)) for k in 1:L) + + @info "BUG rank-adaptive bond growth" Dstart Dloose Dtight + @test Dtight > Dstart # rank-adaptivity grows the bond + @test Dloose < Dtight # a looser tolerance keeps a smaller bond + end + + # 2. THE HARD GATE: overlap-error vs the dense exp(-iHT) reference decreases (monotonically, up + # to the plateau at the fixed-dt floor) as the truncation tolerance ϑ shrinks. This proves the + # augmentation actually captures the true dynamics. + @testset "accuracy improves as ϑ decreases" begin + Random.seed!(202) + L = 6 + H = force_planar(transverse_field_ising(ComplexF64, Trivial; L)) + ψ₀ = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^2)) # low-rank start the dynamics grows + normalize!(ψ₀) + + Hmat = convert(TensorMap, H) + ψvec = convert(TensorMap, ψ₀) + ψvec /= norm(ψvec) + + T = 0.2 + dt = 0.05 + n = round(Int, T / dt) + ref = exp(-im * Hmat * (n * dt)) * ψvec + + ϑs = [1.0e-2, 1.0e-4, 1.0e-6] + errs = map(ϑs) do ϑ + alg = BUG(; trscheme = truncerror(; atol = ϑ)) + ψ = copy(ψ₀) + envs = environments(ψ, H, ψ) + for k in 0:(n - 1) + timestep!(ψ, H, k * dt, dt, alg, envs) + end + ψout = convert(TensorMap, ψ) + ψout /= norm(ψout) + return 1 - abs(dot(ψout, ref)) + end + + @info "BUG rank-adaptive accuracy vs ϑ" ϑs errs + for i in 1:(length(ϑs) - 1) + @test errs[i + 1] ≤ 1.5 * errs[i] # monotone within plateau noise near the dt-floor + end + @test errs[end] < errs[1] / 10 # clear net improvement toward the dt-floor + end + + # 3. CBE-style comparison: from a low-rank state, rank-adaptive BUG tracks a bond-adaptive TDVP2 + # reference better than fixed-rank `BUG()` does (mirrors the CBE-TDVP test). + @testset "tracks TDVP2 better than fixed-rank BUG" begin + Random.seed!(303) + L = 8 + H = force_planar(heisenberg_XXX(Float64, Trivial; spin = 1 // 2, L)) + Dstart, Dcap, dt = 2, 16, 0.05 + ψ₀ = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^Dstart)) + + ref, adaptive, fixed = ψ₀, ψ₀, ψ₀ + for _ in 1:6 + ref, = timestep(ref, H, 0.0, dt, TDVP2(; trscheme = truncrank(Dcap))) + adaptive, = timestep(adaptive, H, 0.0, dt, BUG(; trscheme = truncerror(; atol = 1.0e-8))) + fixed, = timestep(fixed, H, 0.0, dt, BUG()) + end + + @test dim(left_virtualspace(adaptive, L ÷ 2)) > Dstart # adaptive grew the bond + @test dim(left_virtualspace(fixed, L ÷ 2)) == Dstart # fixed-rank stuck at Dstart + @test abs(dot(ref, adaptive)) > abs(dot(ref, fixed)) # and tracks the reference better + end + + # 4. imaginary-time ground-state search: from a low bond dim, rank-adaptive imaginary-time BUG + # grows the bond and lowers the energy toward the true ground state. + @testset "imaginary-time grows bond and lowers energy" begin + Random.seed!(404) + L = 8 + H = force_planar(heisenberg_XXX(Float64, Trivial; spin = 1 // 2, L)) + ψgs, = find_groundstate(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^16), H; verbosity = 0) + Egs = real(expectation_value(ψgs, H)) + + ψ = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^2)) # low bond dim + Dstart = maximum(dim(left_virtualspace(ψ, k)) for k in 1:L) + E_start = real(expectation_value(ψ, H)) + for _ in 1:30 + ψ, = timestep(ψ, H, 0.0, 0.1, BUG(; trscheme = truncerror(; atol = 1.0e-8)); imaginary_evolution = true) + end + Dend = maximum(dim(left_virtualspace(ψ, k)) for k in 1:L) + E_end = real(expectation_value(ψ, H)) + + @info "BUG rank-adaptive imaginary-time" Dstart Dend E_start E_end Egs + @test Dend > Dstart # the bond grew as entanglement built up + @test E_end < E_start - 1.0 # substantial lowering + @test E_end ≈ Egs atol = 0.6 # toward the true ground state (loose) + @test norm(ψ) ≈ 1 atol = 1.0e-6 # imaginary-time renormalizes each step + end + + # 5. real-time energy conservation with truncation (loose atol) and norm behaviour: for a small + # tolerance the truncation is negligible, so the norm stays ≈ 1. + @testset "real-time energy conservation and norm" begin + Random.seed!(505) + L = 6 + H = force_planar(transverse_field_ising(ComplexF64, Trivial; L)) + ψ₀ = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4)) + normalize!(ψ₀) + E₀ = real(expectation_value(ψ₀, H)) + + ψ = ψ₀ + for k in 0:4 + ψ, = timestep(ψ, H, k * 0.05, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-8))) + end + @test real(expectation_value(ψ, H)) ≈ E₀ atol = 1.0e-2 # energy conserved (loose) + @test norm(ψ) ≈ 1 atol = 1.0e-6 # tiny ϑ ⇒ norm preserved + end +end + # Charge-sector (symmetric-tensor) coverage for the fixed-rank BUG. These use *genuine* # symmetric tensors (no `force_planar`), exercising the graded-bond paths flagged in the design # doc's hsector risk register (H1/H6/H7): the transport-tensor seed `isomorphism(V ← V)`, the From 9d1f4bec6f8da36db13f804f29c989d1a97c4753 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 7 Jul 2026 20:42:15 -0400 Subject: [PATCH 06/23] BUG: sector-adaptive rank truncation tests Add symmetric + rank-adaptive coverage exercising the dynamic-grading path: total boundary charge preserved across rank-adaptive steps (Sz=0 and Sz=1), per-sector bond growth, time-dependent per-sector multiplicities, accuracy vs a dense reference improving as the tolerance shrinks, imaginary-time ground-state growth, and the H4 gate where a global rank cut truncates a whole charge sector to dimension 0 and subsequent steps run cleanly through the reduced grading. No implementation change needed; the augmentation/transport/SvdCut machinery handles graded bonds. Documented finding: single-site BUG does not recover a sector once truncated away (re-growth is expected to need the Stage-3 parallel coupling). Co-Authored-By: Claude Opus 4.8 (1M context) --- test/algorithms/bug.jl | 182 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/test/algorithms/bug.jl b/test/algorithms/bug.jl index 60885d52b..2cbee783d 100644 --- a/test/algorithms/bug.jl +++ b/test/algorithms/bug.jl @@ -343,3 +343,185 @@ end @test right_virtualspace.(Ref(ψi), 1:L) == Vr₀ end end + +# Charge-sector RANK-ADAPTIVE coverage (Chunk 2.3): the "sector-adaptivity" path — genuine +# symmetric tensors under a *truncating* `trscheme`, so bonds grow and shrink per sector. This is +# the hardest part of the design doc; it stresses the H2–H5/H10 pitfalls of the risk register: +# augmentation as a per-sector direct sum (H2/H10), the "old basis first" invariant per sector +# (H3), a global-ϑ truncation dropping a sector to dimension 0 ⇒ dynamic bond grading (H4), and +# total-boundary-charge conservation through the zero-block embeddings (H5). All states start +# genuinely low-rank so the dynamics *must* grow the bonds; no `force_planar`. +@testset "BUG rank-adaptive symmetric" verbose = true begin + # A genuinely low-rank U(1) start. For spin-1/2 the virtual bonds alternate integer / + # half-integer parity, so a single-sector cap collapses the state (no fusion channels); use a + # mixed-parity cap with multiplicity 1 per sector. The full-rank Sz=0 profile is [1,2,4,8,4,2]; + # this cap gives [1,2,3,2,3,2], leaving room (esp. on the middle bond) for per-sector growth. + u1cap = U1Space(-1 // 2 => 1, 1 // 2 => 1, 0 => 1, 1 => 1, -1 => 1) + function low_rank_u1(L; seed = 2718, right = U1Space(0 => 1)) + Random.seed!(seed) + H = heisenberg_XXX(ComplexF64, U1Irrep; spin = 1 // 2, L) + ψ = FiniteMPS(physicalspace(H), u1cap; right) + normalize!(ψ) + return H, ψ + end + maxbond(ψ) = maximum(dim(left_virtualspace(ψ, k)) for k in 1:length(ψ)) + secmults(V) = [c => dim(V, c) for c in sectors(V)] + + # 1. THE CORE GATE: rank-adaptivity under symmetry grows the bond per sector for a tight ϑ and + # keeps it small for a loose ϑ, without any `SpaceMismatch`, while the total boundary charge + # (the fixed `right` virtual space) is preserved. Checked in the natural total-Sz=0 sector and + # in a fixed nonzero-charge (Sz=1) sector. + @testset "rank-adaptivity + total-charge preservation (Sz = $label)" for (label, right) in + (("0", U1Space(0 => 1)), ("1", U1Space(1 => 1))) + L = 6 + H, ψ₀ = low_rank_u1(L; right) + Rtot = right_virtualspace(ψ₀, L) + Dstart = maxbond(ψ₀) + + ψtight = ψ₀ + for k in 0:2 + ψtight, = timestep(ψtight, H, k * 0.05, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-10))) + end + Dtight = maxbond(ψtight) + + ψloose = ψ₀ + for k in 0:2 + ψloose, = timestep(ψloose, H, k * 0.05, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-2))) + end + Dloose = maxbond(ψloose) + + @info "BUG rank-adaptive symmetric bond growth (Sz=$label)" Dstart Dloose Dtight + @test Dtight > Dstart # per-sector augmentation grows the bond + @test Dloose < Dtight # a looser tolerance keeps a smaller bond + @test right_virtualspace(ψtight, L) == Rtot # H5: total boundary charge preserved + @test right_virtualspace(ψloose, L) == Rtot + @test norm(ψtight) ≈ 1 atol = 1.0e-6 # tiny ϑ ⇒ negligible truncation + end + + # 2. THE HARD GATE: overlap-error vs a *dense* `exp(-iH·T)` reference decreases as ϑ shrinks + # (monotonically up to the fixed-dt plateau). This proves per-sector augmentation+truncation + # actually captures the true dynamics under symmetry, not just that some bond grows. + @testset "accuracy improves as ϑ decreases (U(1))" begin + L = 6 + H, ψ₀ = low_rank_u1(L; seed = 202) + + Hmat = convert(TensorMap, H) + ψvec = convert(TensorMap, ψ₀) + ψvec /= norm(ψvec) + + T = 0.2 + dt = 0.05 + n = round(Int, T / dt) + ref = exp(-im * Hmat * (n * dt)) * ψvec + + ϑs = [1.0e-2, 1.0e-4, 1.0e-6] + errs = map(ϑs) do ϑ + ψ = copy(ψ₀) + for k in 0:(n - 1) + ψ, = timestep(ψ, H, k * dt, dt, BUG(; trscheme = truncerror(; atol = ϑ))) + end + ψout = convert(TensorMap, ψ) + ψout /= norm(ψout) + return 1 - abs(dot(ψout, ref)) + end + + @info "BUG rank-adaptive symmetric accuracy vs ϑ" ϑs errs + for i in 1:(length(ϑs) - 1) + @test errs[i + 1] ≤ 1.5 * errs[i] # monotone within plateau noise near the dt-floor + end + # Clear net improvement toward the dt-floor. This charge-capped low-rank system saturates + # its (small) per-sector bonds quickly: at ϑ ≲ 1e-4 no directions are truncated, so the two + # tightest tolerances land on the *identical* pure-dt-discretization floor (the ϑ=1e-4 and + # 1e-6 errors coincide to the digit). The net truncation-limited improvement is therefore + # modest (≈2.5×) rather than the orders of magnitude a full-rank trivial system shows. + @test errs[end] < errs[1] / 2 + end + + # 3. DYNAMIC BOND GRADING: an interior bond's per-sector multiplicities + # `[dim(V, c) for c in sectors(V)]` are time-dependent — they change across a single + # rank-adaptive step (the graded structure is not fixed), while the total charge is. + @testset "dynamic bond grading (per-sector multiplicities are time-dependent)" begin + L = 6 + H, ψ₀ = low_rank_u1(L) + mid = 4 + before = secmults(left_virtualspace(ψ₀, mid)) + ψ, = timestep(ψ₀, H, 0.0, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-10))) + after = secmults(left_virtualspace(ψ, mid)) + @info "BUG rank-adaptive symmetric dynamic grading" before after + @test before != after # graded structure evolved + @test right_virtualspace(ψ, L) == right_virtualspace(ψ₀, L) # ... at fixed total charge + end + + # 4. H4 — a global-ϑ cut can truncate a whole sector to dimension 0 (removing it from the bond), + # and downstream rank-adaptive steps must tolerate the changed / asymmetric grading. + # + # NOTE on the sharpest H4 test (deterministic *drop-and-re-add* in one run): this is NOT + # achievable with single-site BUG, and asserting it would be wrong. The augmentation candidate + # `ACᵢ` in each half-sweep always carries the (already-truncated) bond as its *domain*, so + # `_bug_augment_left`/`_bug_augment_right` can only append directions whose charge sectors are + # already present on that bond — they grow multiplicity within existing sectors but can never + # re-introduce a sector that was truncated away (the graded analog of single-site TDVP's + # inability to change bond quantum numbers; re-adding a sector needs a two-site / CBE-style + # candidate, cf. `OptimalExpand`). We verified empirically that a sector dropped from an + # interior bond does not reappear over many subsequent tight steps. We therefore assert the + # deterministic *drop* and the H4 tolerance requirement (subsequent steps run without + # `SpaceMismatch`, yield a valid normalizable state, and preserve the total charge). + @testset "sector drop-to-zero + dynamic-grading tolerance (H4)" begin + L = 6 + H, ψ₀ = low_rank_u1(L) + # grow a rich interior bond that carries the subdominant ±1 sectors + ψrich = ψ₀ + for k in 0:5 + ψrich, = timestep(ψrich, H, k * 0.05, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-10))) + end + mid = 3 + Rtot = right_virtualspace(ψrich, L) + @test U1Irrep(-1) in sectors(left_virtualspace(ψrich, mid)) # subdominant sector present + + # a global rank cut pools singular values across all sectors under one threshold, so the + # subdominant sector is truncated to dimension 0 and removed from the bond (H4). + ψdrop, = timestep(ψrich, H, 0.0, 0.05, BUG(; trscheme = truncrank(2))) + @test !(U1Irrep(-1) in sectors(left_virtualspace(ψdrop, mid))) # dropped to dim 0 + @test right_virtualspace(ψdrop, L) == Rtot # ... charge still preserved + + # subsequent rank-adaptive steps must tolerate the reduced / asymmetric grading: no + # SpaceMismatch, a valid normalizable state, and a conserved total charge. + ψcont = ψdrop + for k in 0:3 + ψcont, = timestep(ψcont, H, k * 0.05, 0.05, BUG(; trscheme = truncerror(; atol = 1.0e-10))) + end + @info "BUG rank-adaptive symmetric H4" dropped = sectors(left_virtualspace(ψdrop, mid)) continued = sectors(left_virtualspace(ψcont, mid)) + @test isfinite(real(expectation_value(ψcont, H))) + @test norm(ψcont) > 0 + @test right_virtualspace(ψcont, L) == Rtot + end + + # 5. IMAGINARY-TIME symmetric rank-adaptive ground-state search: from a low bond dim it grows the + # per-sector bonds, lowers the energy toward `find_groundstate`, and preserves both the total + # charge and the (renormalized) norm. + @testset "imaginary-time symmetric grows bond and lowers energy" begin + L = 6 + H, ψ₀ = low_rank_u1(L) + maxV = MPSKit.max_virtualspaces(physicalspace(H)) + ψgs, = find_groundstate(FiniteMPS(physicalspace(H), maxV[2:(end - 1)]), H; verbosity = 0) + Egs = real(expectation_value(ψgs, H)) + + Rtot = right_virtualspace(ψ₀, L) + Dstart = maxbond(ψ₀) + E_start = real(expectation_value(ψ₀, H)) + + ψ = ψ₀ + for _ in 1:30 + ψ, = timestep(ψ, H, 0.0, 0.1, BUG(; trscheme = truncerror(; atol = 1.0e-8)); imaginary_evolution = true) + end + Dend = maxbond(ψ) + E_end = real(expectation_value(ψ, H)) + + @info "BUG rank-adaptive symmetric imaginary-time" Dstart Dend E_start E_end Egs + @test Dend > Dstart # per-sector bonds grew as entanglement built up + @test E_end < E_start - 1.0 # substantial lowering + @test E_end ≈ Egs atol = 0.1 # toward the true ground state + @test norm(ψ) ≈ 1 atol = 1.0e-6 # imaginary-time renormalizes each step + @test right_virtualspace(ψ, L) == Rtot # total charge preserved throughout + end +end From 27cbc774cfdf51c8d88947256aec7c735990edeb Mon Sep 17 00:00:00 2001 From: lkdvos Date: Wed, 8 Jul 2026 07:35:27 -0400 Subject: [PATCH 07/23] BUG: expand changelog entry Describe the delivered BUG integrator (symmetric 2nd-order, no backward substep, rank-adaptive via a truncating trscheme) rather than just the struct. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/src/changelog.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/src/changelog.md b/docs/src/changelog.md index 7d0fbb154..c1bc75339 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -21,7 +21,10 @@ When releasing a new version, move the "Unreleased" changes to a new version sec ### Added -- `BUG` time-evolution algorithm: a Basis-Update & Galerkin integrator for finite MPS +- `BUG` time-evolution algorithm: a symmetric second-order Basis-Update & Galerkin integrator for + finite MPS. Unlike `TDVP` it has no backward-in-time substep (stable for imaginary-time evolution), + and passing a truncating `trscheme` enables rank-adaptivity (the bond dimension grows and shrinks + automatically to track entanglement). ### Changed From 9425646d8904a694de63712cae07f1fdcc845d4a Mon Sep 17 00:00:00 2001 From: lkdvos Date: Wed, 8 Jul 2026 17:54:39 -0400 Subject: [PATCH 08/23] BUG: simplify + generalize toward TDVP style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring src/algorithms/timestep/bug.jl closer to tdvp.jl in structure and comment density, without changing the algorithm: - timestep!: drop the length(ψ)==1 branch and the fixed/adaptive if/else; two sweeps, each optionally followed by _bug_truncate! gated on _bug_truncates(alg). - Merge the four sweep helpers into two. The only fixed-vs-adaptive difference (install new isometry vs. augment old-first) is isolated into _bug_leftbasis/_bug_rightbasis; the leaf folds into a single loop+edge-case like TDVP, removing the separate envs_old. - Rewrite the transport helpers with partition-based multiplication (_transpose_tail/_mul_front + adjoint) instead of @plansor with a hard-coded single physical index, so BUG now supports MPS with any number of physical legs. - Trim the augment/sweep comment blocks to TDVP density; augment helpers keep their (Û, M) return. All 140 BUG tests (bug.jl + bug_augment.jl) stay green. Co-Authored-By: Claude Opus 4.8 --- src/algorithms/timestep/bug.jl | 290 +++++++++------------------------ 1 file changed, 77 insertions(+), 213 deletions(-) diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 50af2bc2e..b4bf70674 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -5,21 +5,21 @@ Single site MPS time-evolution algorithm based on the Basis-Update & Galerkin (B an unconventional robust integrator for dynamical low-rank approximation. Unlike [`TDVP`](@ref), BUG advances both the basis-carrying (K-step) and the core (Galerkin C-step) -tensors *forward* in time and never inverts the bond tensor. In particular it has no backward-in-time -substep, which makes it a natural choice for imaginary-time / dissipative evolution where the -backward core step of projector-splitting integrators can become unstable. +tensors *forward* in time. In particular there is no backward-in-time substep, which makes it a +natural choice for imaginary-time / dissipative evolution where the backward core step of TDVP +integrators can become unstable. Passing a truncating `trscheme` (anything other than the default `notrunc()`) switches on **rank-adaptivity**: each half-sweep augments every bond with the new directions discovered by the evolved connecting tensor (keeping the old basis as the leading block, `[U₀ │ K₁]`) and then -truncates the enlarged bonds back down to the tolerance of `trscheme` by an optimal SVD sweep. The -bond dimension therefore grows and shrinks automatically to track the entanglement of the evolving -state. The default `notrunc()` recovers the fixed-rank integrator exactly. +truncates the enlarged bonds back down to the tolerance of `trscheme` by an truncation sweep. +The bond dimension therefore grows and shrinks automatically to track the entanglement of the +evolving state. The default `notrunc()` recovers the fixed-rank integrator exactly. !!! note - Real-time evolution does not renormalize: neither the augmentation nor the truncation - renormalizes, so the state norm reflects the accumulated truncation error. Imaginary-time - evolution renormalizes after every half-sweep, like a ground-state search. + Real-time evolution does not normalize the resulting state: neither the augmentation nor + the truncation normalizes, so the state norm reflects the accumulated truncation error. + Imaginary-time evolution renormalizes after every half-sweep, similar to a ground-state search. ## Fields @@ -65,92 +65,53 @@ function timestep!( envs::AbstractMPSEnvironments = environments(ψ, H, ψ); imaginary_evolution::Bool = false ) - # Symmetric (2nd-order) fixed-rank BUG. Following the rooted TTN recursion (source of - # truth for the MPS caterpillar), one *first-order* BUG sweep evolves every site tensor - # exactly once: a K-step (basis update) at the leaf boundary and a forward Galerkin - # AC-step at every internal node, where the *un-evolved* old connecting tensor is first - # reprojected onto the already-updated child bases. A single symmetrized time step is a - # left→right half-sweep with step `dt/2` composed with its mirror right→left half-sweep, - # which lifts the sequential first-order sweep to second order. - # - # This deliberately does *not* mirror TDVP's per-site "evolve AC / split / evolve the - # split-off core backward" substep, nor a naive per-site "K-step + separate forward - # Galerkin C-step": the latter re-evolves the moving core once per bond and is only - # first-order-consistent to the wrong ODE (the state converges to the wrong direction as - # `dt → 0`). BUG has no backward substep, so imaginary-time evolution stays stable; the - # state is renormalized after each half-sweep when `imaginary_evolution = true`. - if length(ψ) == 1 - Hac = AC_hamiltonian(1, ψ, H, ψ, envs) - ψ.AC[1] = integrate(Hac, ψ.AC[1], t, dt, alg.integrator; imaginary_evolution) - imaginary_evolution && normalize!(ψ) - return ψ, envs - end - + # symmetric 2nd-order: a dt/2 left→right half-sweep composed with its dt/2 mirror. A truncating + # `trscheme` truncates the augmented state back down after each half-sweep (rank-adaptive BUG). h = dt / 2 - if _bug_truncates(alg) - # Rank-adaptive BUG: each half-sweep *augments* every bond with the new directions - # discovered by the (Galerkin-)evolved connecting tensor, evolving the core on the - # enlarged (old-first) basis, and is followed by an optimal SVD truncation back down to - # the `trscheme` tolerance (Ceruti–Lubich–Walach 2022). Real-time evolution does not - # renormalize, so the truncation error is reflected in the norm; imaginary-time - # evolution renormalizes each half-sweep. - _bug_sweep_right_adaptive!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right - _bug_truncate!(ψ, alg; normalize = imaginary_evolution) - _bug_sweep_left_adaptive!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left - _bug_truncate!(ψ, alg; normalize = imaginary_evolution) - else - _bug_sweep_right!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right - _bug_sweep_left!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left - end + _bug_sweep_right!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right + _bug_truncates(alg) && _bug_truncate!(ψ, alg; normalize = imaginary_evolution) + _bug_sweep_left!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left + _bug_truncates(alg) && _bug_truncate!(ψ, alg; normalize = imaginary_evolution) return ψ, envs end # Rank-adaptivity is enabled by any truncating `trscheme` (the default `notrunc()` selects the -# byte-for-byte fixed-rank path). Mirrors the `_truncates` gate used by `TDVP`/`DMRG`. +# fixed-rank path). Mirrors the `_truncates` gate used by `TDVP`/`DMRG`. _bug_truncates(alg::BUG) = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) -# Truncate every internal bond back down to the `trscheme` tolerance with an optimal -# (canonical-form) SVD sweep. This is the "discard the singular-value tail whose Frobenius norm -# ≤ ϑ" step of the rank-adaptive integrator, applied to the exact augmented-Galerkin state -# produced by a half-sweep. The effective environments passed to the next half-sweep are keyed on +# Truncate every internal bond back down to the `trscheme` tolerance with an optimal SVD sweep, +# discarding the singular-value tail of the augmented-Galerkin state. Environments are keyed on # tensor identity, so they recompute lazily for the changed bonds — no explicit refresh needed. function _bug_truncate!(ψ, alg::BUG; normalize::Bool = false) changebonds!(ψ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) return ψ end -# Transport of the old→new basis overlap across one bond. -# `transport_right`: given the overlap `T` on bond `i` (mapping the new child bond to the old -# child bond, `old ← new`) and the old/new right-isometries at site `i`, returns the overlap on -# bond `i-1`. `transport_left` is the mirror (`new ← old`) for the left-to-right sweep. -function _bug_transport_right(AR_old, AR_new, T) - @plansor Tnew[-1; -2] := AR_old[-1 1; 2] * T[2; 3] * conj(AR_new[-2 1; 3]) - return Tnew +# Transport the old→new bond overlap across one site, contracting the front bond + all physical +# legs. `_bug_transport_left` propagates `T` (new ← old) rightward, `_bug_transport_right` the +# mirror (old ← new). Partition-based, so any number of physical legs is supported. +_bug_transport_left(AL_old, AL_new, T) = AL_new' * _mul_front(T, AL_old) +_bug_transport_right(AR_old, AR_new, T) = + _transpose_tail(AR_old * T) * _transpose_tail(AR_new)' + +# New basis + core for one internal node. Fixed-rank keeps the evolved isometry; rank-adaptive +# augments it old-first (`[U₀ │ K₁]`), deferring the cut to `_bug_truncate!`. The tuple order +# matches `ψ.AC[i]`: `(AL, C)` for a left→right sweep, `(C, AR)` for a right→left sweep. +function _bug_leftbasis(alg::BUG, U₀, AC) + _bug_truncates(alg) || return left_gauge(AC, alg.alg_orth) + Û, _ = _bug_augment_left(U₀, AC, alg.alg_orth) + return Û, Û' * AC end -function _bug_transport_left(AL_old, AL_new, T) - @plansor Tnew[-1; -2] := conj(AL_new[1 2; -1]) * T[1; 3] * AL_old[3 2; -2] - return Tnew +function _bug_rightbasis(alg::BUG, U₀, AC) + _bug_truncates(alg) || return right_gauge(AC, alg.alg_orth) + Û, _ = _bug_augment_right(U₀, AC, alg.alg_orth) + return _transpose_tail(AC) * _transpose_tail(Û)', Û end -# Basis augmentation (Stage 2 building block; NOT yet wired into `timestep!`). -# -# `_bug_augment_left` augments the RIGHT virtual bond of a site for the left→right sweep. -# Given the OLD left-isometry `U₀` (an MPS tensor `Vl ⊗ P ← Vr₀`, i.e. `AL_old`) and the evolved -# single-site candidate `K₁` (same leg structure `Vl ⊗ P ← Vr_K`, the K-step / Galerkin output), -# it builds an augmented left-isometry `Û` (`Vl ⊗ P ← V̂`) whose column space contains both -# `range(U₀)` and `range(K₁)`, keeping the OLD basis as the leading per-sector block -# (`V̂ = Vr₀ ⊕ Vr_new`). This is the "old-basis-first" augmentation `[U₀ │ Ũ₁]` of the -# rank-adaptive BUG papers, which makes the reprojection `Ŝ₀ = Û* Y₀ = Y₀` exact. -# -# The appended directions come from the *single-site* candidate `K₁` (not a two-site -# `AC2_projection` as in `OptimalExpand`): take the component of `K₁` in the orthogonal complement -# of `U₀` (`left_null`), orthonormalize its column space, and `catdomain` it after `U₀`. This does -# NOT truncate — the appended block has the full rank of that complement (so `dim(V̂) ≤ 2·dim(Vr₀)`). -# -# Returns `(Û, M)`: -# * `Û` — the augmented left-isometry (`Û' Û = 𝟙`, `Vl ⊗ P ← V̂`), -# * `M = Û' * U₀` — the old bond's coordinates in the augmented basis (`V̂ ← Vr₀`), equal to -# `[𝟙; 0]` per sector, ready to embed the old transport/core into the enlarged bond. +# Augment the RIGHT bond for the left→right sweep: given the old left-isometry `U₀` (`AL_old`) and +# the evolved candidate `K₁`, build `Û = [U₀ │ Ũ₁]` (`Vl⊗P ← Vr₀ ⊕ Vr_new`) whose column space +# contains both `range(U₀)` and `range(K₁)`, keeping the old basis as the leading per-sector block +# (no truncation). Returns `(Û, M)` with `M = Û' U₀ = [𝟙; 0]` the old bond's coordinates in `Û`. function _bug_augment_left(U₀, K₁, alg_orth = Defaults.alg_orth()) N = left_null(U₀) # Vl⊗P ← Vc, orthonormal complement of range(U₀) g = N' * K₁ # Vc ← Vr_K, the part of K₁ orthogonal to U₀ @@ -161,17 +122,10 @@ function _bug_augment_left(U₀, K₁, alg_orth = Defaults.alg_orth()) return Û, M end -# `_bug_augment_right` is the mirror for the right→left sweep: it augments the LEFT virtual bond, -# working on the `_transpose_tail` form (`Vl ← P ⊗ Vr`, in which a right-isometry has orthonormal -# rows) and using `right_null!`/`catcodomain`, exactly as `changebond!(:left)` and `right_gauge` do. -# Given the OLD right-isometry `U₀` (`Vl₀ ⊗ P ← Vr`, i.e. `AR_old`) and the evolved candidate `K₁` -# (`Vl_K ⊗ P ← Vr`), it returns an augmented right-isometry `Û` (`V̂ ⊗ P ← Vr`) whose left bond is -# `V̂ = Vl₀ ⊕ Vl_new` (old-first) with row space containing both `U₀` and `K₁`. -# -# Returns `(Û, M)`: -# * `Û` — the augmented right-isometry (`Û` is right-canonical, its tail satisfies `û û' = 𝟙`), -# * `M = û * u₀'` (with `û = _transpose_tail(Û)`, `u₀ = _transpose_tail(U₀)`) — the old bond's -# coordinates in the augmented left bond (`V̂ ← Vl₀`), equal to `[𝟙; 0]` per sector. +# Mirror of `_bug_augment_left` for the right→left sweep: augment the LEFT bond on the +# `_transpose_tail` form (`Vl ← P⊗Vr`, in which a right-isometry has orthonormal rows). Given the +# old right-isometry `U₀` (`AR_old`) and candidate `K₁`, returns `(Û, M)` with `Û` right-canonical +# on `V̂ = Vl₀ ⊕ Vl_new` (old-first) and `M = û u₀' = [𝟙; 0]` per sector. function _bug_augment_right(U₀, K₁, alg_orth = Defaults.alg_orth()) u₀ = _transpose_tail(U₀) # Vl₀ ← P⊗Vr, right-isometric (u₀ u₀' = 𝟙) k₁ = _transpose_tail(K₁) # Vl_K ← P⊗Vr @@ -185,144 +139,54 @@ function _bug_augment_right(U₀, K₁, alg_orth = Defaults.alg_orth()) return Û, M end -# Left→right half-sweep (root = last site, leaf = site 1): center ends at the last site. +# Left→right half-sweep (root = last site, leaf = site 1): reproject the frozen old connecting +# tensor onto the already-updated left bases, evolve it forward, and install the new left basis. function _bug_sweep_right!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) L = length(ψ) - ψ.AC[1] # gauge center to site 1 (materialize AR[2..L]) - ψ_old = copy(ψ) # frozen "t₀" state for K-step inputs / old bases - envs_old = environments(ψ_old, H, ψ_old) - # `ψ` is mutated in place: its left bases become new (as installed), its right bases stay - # `ψ_old`'s, so `envs` yields new-left / old-right effective Hamiltonians for the Galerkin. - - # leaf (site 1): K-step, keep only the new left isometry - AC1 = integrate(AC_hamiltonian(1, ψ_old, H, ψ_old, envs_old), ψ_old.AC[1], t, τ, alg.integrator; imaginary_evolution) - AL_new, C_new = left_gauge(AC1, alg.alg_orth) - T = isomorphism(scalartype(ψ_old), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) - T = _bug_transport_left(ψ_old.AL[1], AL_new, T) # overlap on bond 1 (new ← old) - ψ.AC[1] = (AL_new, C_new) + ψ.AC[1] # gauge center to site 1 + ψ_old = copy(ψ) # frozen bases / reprojection inputs + T = isomorphism(scalartype(ψ), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) - for i in 2:L + for i in 1:(L - 1) Ĉ = _mul_front(T, ψ_old.AC[i]) # reproject old connecting tensor - ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) - if i == L - imaginary_evolution && normalize!(ACi) - ψ.AC[L] = ACi - else - AL_new, C_new = left_gauge(ACi, alg.alg_orth) - T = _bug_transport_left(ψ_old.AL[i], AL_new, T) - ψ.AC[i] = (AL_new, C_new) - end + AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) + U, C = _bug_leftbasis(alg, _mul_front(T, ψ_old.AL[i]), AC) + T = _bug_transport_left(ψ_old.AL[i], U, T) + ψ.AC[i] = (U, C) end + + # root (site L): evolve, no further basis update + AC = integrate( + AC_hamiltonian(L, ψ, H, ψ, envs), _mul_front(T, ψ_old.AC[L]), + t, τ, alg.integrator; imaginary_evolution + ) + imaginary_evolution && normalize!(AC) + ψ.AC[L] = AC return ψ end -# Right→left half-sweep (root = first site, leaf = last site): center ends at the first site. +# Right→left half-sweep (root = first site, leaf = last site): the mirror of `_bug_sweep_right!`. function _bug_sweep_left!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) L = length(ψ) - ψ.AC[L] # gauge center to last site (materialize AL[1..L-1]) + ψ.AC[L] # gauge center to site L ψ_old = copy(ψ) - envs_old = environments(ψ_old, H, ψ_old) - - # leaf (site L): K-step, keep only the new right isometry - ACL = integrate(AC_hamiltonian(L, ψ_old, H, ψ_old, envs_old), ψ_old.AC[L], t, τ, alg.integrator; imaginary_evolution) - C_new, AR_new = right_gauge(ACL, alg.alg_orth) - T = isomorphism(scalartype(ψ_old), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) - T = _bug_transport_right(ψ_old.AR[L], AR_new, T) # overlap on bond L-1 (old ← new) - ψ.AC[L] = (C_new, AR_new) + T = isomorphism(scalartype(ψ), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) - for i in (L - 1):-1:1 + for i in L:-1:2 Ĉ = ψ_old.AC[i] * T # reproject old connecting tensor - ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) - if i == 1 - imaginary_evolution && normalize!(ACi) - ψ.AC[1] = ACi - else - C_new, AR_new = right_gauge(ACi, alg.alg_orth) - T = _bug_transport_right(ψ_old.AR[i], AR_new, T) - ψ.AC[i] = (C_new, AR_new) - end - end - return ψ -end - -# Rank-adaptive half-sweeps -# ------------------------- -# These mirror `_bug_sweep_right!`/`_bug_sweep_left!` exactly, except that at every internal node -# the fixed-rank `left_gauge`/`right_gauge` is replaced by an *augmentation*: the evolved -# connecting tensor `ACᵢ` is used as the candidate `K₁` and its directions orthogonal to the -# (transported) old isometry are appended, keeping the old basis as the leading per-sector block -# (`_bug_augment_left`/`_bug_augment_right`). The augmented isometry `Û` (rank up to 2r) is -# installed as the new left/right basis *without in-sweep truncation*: the enlarged bond is then -# seen by the next node's Galerkin evolution (which fills the new directions), exactly the -# augmented-basis Galerkin of the rank-adaptive matrix/TTN integrator. The bond factor -# `C_new = Û* ACᵢ` reconstructs `ACᵢ` exactly (`range(ACᵢ) ⊆ range(Û)`), so the embedding of the -# half-sweep state is exact; the subsequent `_bug_truncate!` performs the SVD-tail truncation. -# -# NOTE (deviation from the "core = Û* ACᵢ; svd_trunc!" recipe): truncating that core in place -# cannot grow the bond — it is a `(≤2r ← r)` matrix of rank ≤ r, so its SVD keeps ≤ r directions -# and the bond never exceeds the old rank. Growth requires evolving the *next* node on the -# enlarged bond before cutting, which is why truncation is deferred to `_bug_truncate!` (an -# optimal compression of the exact augmented-Galerkin state — equivalent to SVD-truncating the -# augmented core, but globally optimal). -function _bug_sweep_right_adaptive!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) - L = length(ψ) - ψ.AC[1] - ψ_old = copy(ψ) - envs_old = environments(ψ_old, H, ψ_old) - - # leaf (site 1): K-step, then augment the outgoing (right) bond - AC1 = integrate(AC_hamiltonian(1, ψ_old, H, ψ_old, envs_old), ψ_old.AC[1], t, τ, alg.integrator; imaginary_evolution) - Û, _ = _bug_augment_left(ψ_old.AL[1], AC1, alg.alg_orth) # Vl⊗P ← V̂₁ (old-first, up to 2r) - C_new = Û' * AC1 # V̂₁ ← old_bond_1 (exact: Û·C_new = AC1) - T = isomorphism(scalartype(ψ_old), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) - T = _bug_transport_left(ψ_old.AL[1], Û, T) # V̂₁ ← old_bond_1 - ψ.AC[1] = (Û, C_new) - - for i in 2:L - Ĉ = _mul_front(T, ψ_old.AC[i]) # V̂_{i-1} ⊗ P ← old_bond_i - ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) - if i == L - imaginary_evolution && normalize!(ACi) - ψ.AC[L] = ACi - else - U₀ = _mul_front(T, ψ_old.AL[i]) # old isometry in the new left frame - Û, _ = _bug_augment_left(U₀, ACi, alg.alg_orth) # V̂_{i-1} ⊗ P ← V̂_i (grows bond i) - C_new = Û' * ACi # V̂_i ← old_bond_i - T = _bug_transport_left(ψ_old.AL[i], Û, T) # V̂_i ← old_bond_i - ψ.AC[i] = (Û, C_new) - end + AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) + C, U = _bug_rightbasis(alg, ψ_old.AR[i] * T, AC) + T = _bug_transport_right(ψ_old.AR[i], U, T) + ψ.AC[i] = (C, U) end - return ψ -end -function _bug_sweep_left_adaptive!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) - L = length(ψ) - ψ.AC[L] - ψ_old = copy(ψ) - envs_old = environments(ψ_old, H, ψ_old) - - # leaf (site L): K-step, then augment the outgoing (left) bond - ACL = integrate(AC_hamiltonian(L, ψ_old, H, ψ_old, envs_old), ψ_old.AC[L], t, τ, alg.integrator; imaginary_evolution) - Û, _ = _bug_augment_right(ψ_old.AR[L], ACL, alg.alg_orth) # V̂_{L-1} ⊗ P ← Vr (old-first, up to 2r) - C_new = _transpose_tail(ACL) * _transpose_tail(Û)' # old_bond_{L-1} ← V̂_{L-1} (exact) - T = isomorphism(scalartype(ψ_old), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) - T = _bug_transport_right(ψ_old.AR[L], Û, T) # old_bond_{L-1} ← V̂_{L-1} - ψ.AC[L] = (C_new, Û) - - for i in (L - 1):-1:1 - Ĉ = ψ_old.AC[i] * T # old_bond_{i-1} ⊗ P ← V̂_i - ACi = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) - if i == 1 - imaginary_evolution && normalize!(ACi) - ψ.AC[1] = ACi - else - U₀ = ψ_old.AR[i] * T # old isometry in the new right frame - Û, _ = _bug_augment_right(U₀, ACi, alg.alg_orth) # V̂_{i-1} ⊗ P ← V̂_i (grows bond i-1) - C_new = _transpose_tail(ACi) * _transpose_tail(Û)' # old_bond_{i-1} ← V̂_{i-1} - T = _bug_transport_right(ψ_old.AR[i], Û, T) # old_bond_{i-1} ← V̂_{i-1} - ψ.AC[i] = (C_new, Û) - end - end + # root (site 1): evolve, no further basis update + AC = integrate( + AC_hamiltonian(1, ψ, H, ψ, envs), ψ_old.AC[1] * T, + t, τ, alg.integrator; imaginary_evolution + ) + imaginary_evolution && normalize!(AC) + ψ.AC[1] = AC return ψ end From 4909e80adc09db6aade925ed01045418631d3001 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Wed, 8 Jul 2026 19:36:38 -0400 Subject: [PATCH 09/23] BUG: inline the half-sweeps into timestep! Follow TDVP's layout more closely: write both half-sweeps as inline loops plus edge cases directly in timestep!, dropping the _bug_sweep_right!/ _bug_sweep_left! helpers. The per-node basis update (_bug_leftbasis/ _bug_rightbasis) and transport helpers stay factored out, mirroring how TDVP keeps left_gauge/right_gauge as helpers while inlining the sweep loops. No behavior change; all 140 BUG tests stay green. Co-Authored-By: Claude Opus 4.8 --- src/algorithms/timestep/bug.jl | 97 +++++++++++++++------------------- 1 file changed, 42 insertions(+), 55 deletions(-) diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index b4bf70674..5057cfe9d 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -65,13 +65,51 @@ function timestep!( envs::AbstractMPSEnvironments = environments(ψ, H, ψ); imaginary_evolution::Bool = false ) - # symmetric 2nd-order: a dt/2 left→right half-sweep composed with its dt/2 mirror. A truncating - # `trscheme` truncates the augmented state back down after each half-sweep (rank-adaptive BUG). + # symmetric 2nd-order: a dt/2 left→right half-sweep composed with its dt/2 mirror. Each + # half-sweep reprojects the frozen old connecting tensor onto the already-updated bases, evolves + # it forward, and installs the new basis; a truncating `trscheme` then cuts the augmented state + # back down (rank-adaptive BUG). + L = length(ψ) h = dt / 2 - _bug_sweep_right!(ψ, H, t, h, alg, envs; imaginary_evolution) # left → right + + # sweep left to right (root = last site) + ψ.AC[1] # gauge center to site 1 + ψ_old = copy(ψ) # frozen bases / reprojection inputs + T = isomorphism(scalartype(ψ), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) + for i in 1:(L - 1) + Ĉ = _mul_front(T, ψ_old.AC[i]) # reproject old connecting tensor + AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, h, alg.integrator; imaginary_evolution) + U, C = _bug_leftbasis(alg, _mul_front(T, ψ_old.AL[i]), AC) + T = _bug_transport_left(ψ_old.AL[i], U, T) + ψ.AC[i] = (U, C) + end + AC = integrate( + AC_hamiltonian(L, ψ, H, ψ, envs), _mul_front(T, ψ_old.AC[L]), + t, h, alg.integrator; imaginary_evolution + ) + imaginary_evolution && normalize!(AC) + ψ.AC[L] = AC _bug_truncates(alg) && _bug_truncate!(ψ, alg; normalize = imaginary_evolution) - _bug_sweep_left!(ψ, H, t + h, h, alg, envs; imaginary_evolution) # right → left + + # sweep right to left (root = first site), the mirror + ψ.AC[L] # gauge center to site L + ψ_old = copy(ψ) + T = isomorphism(scalartype(ψ), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) + for i in L:-1:2 + Ĉ = ψ_old.AC[i] * T # reproject old connecting tensor + AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t + h, h, alg.integrator; imaginary_evolution) + C, U = _bug_rightbasis(alg, ψ_old.AR[i] * T, AC) + T = _bug_transport_right(ψ_old.AR[i], U, T) + ψ.AC[i] = (C, U) + end + AC = integrate( + AC_hamiltonian(1, ψ, H, ψ, envs), ψ_old.AC[1] * T, + t + h, h, alg.integrator; imaginary_evolution + ) + imaginary_evolution && normalize!(AC) + ψ.AC[1] = AC _bug_truncates(alg) && _bug_truncate!(ψ, alg; normalize = imaginary_evolution) + return ψ, envs end @@ -139,57 +177,6 @@ function _bug_augment_right(U₀, K₁, alg_orth = Defaults.alg_orth()) return Û, M end -# Left→right half-sweep (root = last site, leaf = site 1): reproject the frozen old connecting -# tensor onto the already-updated left bases, evolve it forward, and install the new left basis. -function _bug_sweep_right!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) - L = length(ψ) - ψ.AC[1] # gauge center to site 1 - ψ_old = copy(ψ) # frozen bases / reprojection inputs - T = isomorphism(scalartype(ψ), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) - - for i in 1:(L - 1) - Ĉ = _mul_front(T, ψ_old.AC[i]) # reproject old connecting tensor - AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) - U, C = _bug_leftbasis(alg, _mul_front(T, ψ_old.AL[i]), AC) - T = _bug_transport_left(ψ_old.AL[i], U, T) - ψ.AC[i] = (U, C) - end - - # root (site L): evolve, no further basis update - AC = integrate( - AC_hamiltonian(L, ψ, H, ψ, envs), _mul_front(T, ψ_old.AC[L]), - t, τ, alg.integrator; imaginary_evolution - ) - imaginary_evolution && normalize!(AC) - ψ.AC[L] = AC - return ψ -end - -# Right→left half-sweep (root = first site, leaf = last site): the mirror of `_bug_sweep_right!`. -function _bug_sweep_left!(ψ, H, t, τ, alg, envs; imaginary_evolution::Bool = false) - L = length(ψ) - ψ.AC[L] # gauge center to site L - ψ_old = copy(ψ) - T = isomorphism(scalartype(ψ), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) - - for i in L:-1:2 - Ĉ = ψ_old.AC[i] * T # reproject old connecting tensor - AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, τ, alg.integrator; imaginary_evolution) - C, U = _bug_rightbasis(alg, ψ_old.AR[i] * T, AC) - T = _bug_transport_right(ψ_old.AR[i], U, T) - ψ.AC[i] = (C, U) - end - - # root (site 1): evolve, no further basis update - AC = integrate( - AC_hamiltonian(1, ψ, H, ψ, envs), ψ_old.AC[1] * T, - t, τ, alg.integrator; imaginary_evolution - ) - imaginary_evolution && normalize!(AC) - ψ.AC[1] = AC - return ψ -end - # copying version function timestep( ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, From 14632d4b58439264e70193548509251411915499 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Wed, 8 Jul 2026 20:06:50 -0400 Subject: [PATCH 10/23] BUG: inline the remaining sweep helpers into timestep! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold _bug_leftbasis/_bug_rightbasis (fixed/adaptive basis dispatch), the transport contractions, and the SvdCut truncation directly into timestep!, so the integrator reads as one self-contained function. The per-node fixed vs. adaptive choice is now an inline `if truncates` branch, and the old→new transport is written inline (`U' * U₀` / `_transpose_tail(U₀) * _transpose_tail(U)'`), reusing the U₀ = (old isometry in the new frame) subexpression. Only _bug_augment_left/_bug_augment_right remain as named helpers, since test/algorithms/bug_augment.jl imports and asserts on them directly. Also refresh a stale test comment that referenced the removed _bug_transport_*. No behavior change; all 140 BUG tests stay green. Co-Authored-By: Claude Opus 4.8 --- src/algorithms/timestep/bug.jl | 64 ++++++++++++---------------------- test/algorithms/bug.jl | 4 +-- 2 files changed, 25 insertions(+), 43 deletions(-) diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 5057cfe9d..6351132d6 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -67,10 +67,13 @@ function timestep!( ) # symmetric 2nd-order: a dt/2 left→right half-sweep composed with its dt/2 mirror. Each # half-sweep reprojects the frozen old connecting tensor onto the already-updated bases, evolves - # it forward, and installs the new basis; a truncating `trscheme` then cuts the augmented state - # back down (rank-adaptive BUG). + # it forward, and installs the new basis; a truncating `trscheme` (rank-adaptive BUG) then cuts + # the augmented state back down with an optimal SVD sweep. Fixed-rank keeps the evolved isometry, + # rank-adaptive augments it old-first (`[U₀ │ K₁]`, deferring the cut to `changebonds!`). L = length(ψ) h = dt / 2 + truncates = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) + svdcut = SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd) # sweep left to right (root = last site) ψ.AC[1] # gauge center to site 1 @@ -79,8 +82,14 @@ function timestep!( for i in 1:(L - 1) Ĉ = _mul_front(T, ψ_old.AC[i]) # reproject old connecting tensor AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, h, alg.integrator; imaginary_evolution) - U, C = _bug_leftbasis(alg, _mul_front(T, ψ_old.AL[i]), AC) - T = _bug_transport_left(ψ_old.AL[i], U, T) + U₀ = _mul_front(T, ψ_old.AL[i]) # old left isometry in the new frame + if truncates + U, _ = _bug_augment_left(U₀, AC, alg.alg_orth) + C = U' * AC + else + U, C = left_gauge(AC, alg.alg_orth) + end + T = U' * U₀ # transport (new ← old) ψ.AC[i] = (U, C) end AC = integrate( @@ -89,7 +98,7 @@ function timestep!( ) imaginary_evolution && normalize!(AC) ψ.AC[L] = AC - _bug_truncates(alg) && _bug_truncate!(ψ, alg; normalize = imaginary_evolution) + truncates && changebonds!(ψ, svdcut; normalize = imaginary_evolution) # sweep right to left (root = first site), the mirror ψ.AC[L] # gauge center to site L @@ -98,8 +107,14 @@ function timestep!( for i in L:-1:2 Ĉ = ψ_old.AC[i] * T # reproject old connecting tensor AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t + h, h, alg.integrator; imaginary_evolution) - C, U = _bug_rightbasis(alg, ψ_old.AR[i] * T, AC) - T = _bug_transport_right(ψ_old.AR[i], U, T) + U₀ = ψ_old.AR[i] * T # old right isometry in the new frame + if truncates + U, _ = _bug_augment_right(U₀, AC, alg.alg_orth) + C = _transpose_tail(AC) * _transpose_tail(U)' + else + C, U = right_gauge(AC, alg.alg_orth) + end + T = _transpose_tail(U₀) * _transpose_tail(U)' # transport (old ← new) ψ.AC[i] = (C, U) end AC = integrate( @@ -108,44 +123,11 @@ function timestep!( ) imaginary_evolution && normalize!(AC) ψ.AC[1] = AC - _bug_truncates(alg) && _bug_truncate!(ψ, alg; normalize = imaginary_evolution) + truncates && changebonds!(ψ, svdcut; normalize = imaginary_evolution) return ψ, envs end -# Rank-adaptivity is enabled by any truncating `trscheme` (the default `notrunc()` selects the -# fixed-rank path). Mirrors the `_truncates` gate used by `TDVP`/`DMRG`. -_bug_truncates(alg::BUG) = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) - -# Truncate every internal bond back down to the `trscheme` tolerance with an optimal SVD sweep, -# discarding the singular-value tail of the augmented-Galerkin state. Environments are keyed on -# tensor identity, so they recompute lazily for the changed bonds — no explicit refresh needed. -function _bug_truncate!(ψ, alg::BUG; normalize::Bool = false) - changebonds!(ψ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) - return ψ -end - -# Transport the old→new bond overlap across one site, contracting the front bond + all physical -# legs. `_bug_transport_left` propagates `T` (new ← old) rightward, `_bug_transport_right` the -# mirror (old ← new). Partition-based, so any number of physical legs is supported. -_bug_transport_left(AL_old, AL_new, T) = AL_new' * _mul_front(T, AL_old) -_bug_transport_right(AR_old, AR_new, T) = - _transpose_tail(AR_old * T) * _transpose_tail(AR_new)' - -# New basis + core for one internal node. Fixed-rank keeps the evolved isometry; rank-adaptive -# augments it old-first (`[U₀ │ K₁]`), deferring the cut to `_bug_truncate!`. The tuple order -# matches `ψ.AC[i]`: `(AL, C)` for a left→right sweep, `(C, AR)` for a right→left sweep. -function _bug_leftbasis(alg::BUG, U₀, AC) - _bug_truncates(alg) || return left_gauge(AC, alg.alg_orth) - Û, _ = _bug_augment_left(U₀, AC, alg.alg_orth) - return Û, Û' * AC -end -function _bug_rightbasis(alg::BUG, U₀, AC) - _bug_truncates(alg) || return right_gauge(AC, alg.alg_orth) - Û, _ = _bug_augment_right(U₀, AC, alg.alg_orth) - return _transpose_tail(AC) * _transpose_tail(Û)', Û -end - # Augment the RIGHT bond for the left→right sweep: given the old left-isometry `U₀` (`AL_old`) and # the evolved candidate `K₁`, build `Û = [U₀ │ Ũ₁]` (`Vl⊗P ← Vr₀ ⊕ Vr_new`) whose column space # contains both `range(U₀)` and `range(K₁)`, keeping the old basis as the leading per-sector block diff --git a/test/algorithms/bug.jl b/test/algorithms/bug.jl index 2cbee783d..03cbf0f30 100644 --- a/test/algorithms/bug.jl +++ b/test/algorithms/bug.jl @@ -254,8 +254,8 @@ end # Charge-sector (symmetric-tensor) coverage for the fixed-rank BUG. These use *genuine* # symmetric tensors (no `force_planar`), exercising the graded-bond paths flagged in the design # doc's hsector risk register (H1/H6/H7): the transport-tensor seed `isomorphism(V ← V)`, the -# `@plansor` (co)domain/dual conventions in `_bug_transport_*`, and the adjoints carrying sector -# duals. A fixed-rank step must preserve the total charge and the graded structure of every bond. +# (co)domain/dual conventions of the inlined transport contractions, and the adjoints carrying +# sector duals. A fixed-rank step must preserve the total charge and the graded structure of every bond. @testset "BUG symmetric tensors" verbose = true begin dt = 0.1 L = 6 From e735b42965337cedd307978a4783d11fda900fad Mon Sep 17 00:00:00 2001 From: lkdvos Date: Thu, 9 Jul 2026 08:54:51 -0400 Subject: [PATCH 11/23] BUG: streamline basis augmentation to a single factorization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _bug_augment_left/_bug_augment_right previously built the full orthogonal complement (left_null / right_null!), projected the candidate into it, orthonormalized, and mapped it back (N * Q) — two factorizations plus several contractions — and then computed an overlap M that every production call site discarded. Replace this with a single QR/LQ of the stacked [U₀ │ K₁]: orthonormalizing with U₀ first keeps it as the leading per-sector block and appends only the directions of K₁ orthogonal to it, while the factorization caps each charge sector at dim(Vl⊗P, c) so nothing outside the physical space is ever added (the property left_null used to guarantee). Drop the discarded M return; the augment test now forms M = Û'U₀ itself to check the old-first invariant. All 140 BUG tests stay green (energy conservation, rank-adaptive growth, U(1) symmetric, old-first augmentation invariants). Co-Authored-By: Claude Opus 4.8 --- src/algorithms/timestep/bug.jl | 41 ++++++++++++---------------------- test/algorithms/bug_augment.jl | 6 +++-- 2 files changed, 18 insertions(+), 29 deletions(-) diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 6351132d6..2d04c7765 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -84,7 +84,7 @@ function timestep!( AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, h, alg.integrator; imaginary_evolution) U₀ = _mul_front(T, ψ_old.AL[i]) # old left isometry in the new frame if truncates - U, _ = _bug_augment_left(U₀, AC, alg.alg_orth) + U = _bug_augment_left(U₀, AC, alg.alg_orth) C = U' * AC else U, C = left_gauge(AC, alg.alg_orth) @@ -109,7 +109,7 @@ function timestep!( AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t + h, h, alg.integrator; imaginary_evolution) U₀ = ψ_old.AR[i] * T # old right isometry in the new frame if truncates - U, _ = _bug_augment_right(U₀, AC, alg.alg_orth) + U = _bug_augment_right(U₀, AC, alg.alg_orth) C = _transpose_tail(AC) * _transpose_tail(U)' else C, U = right_gauge(AC, alg.alg_orth) @@ -128,35 +128,22 @@ function timestep!( return ψ, envs end -# Augment the RIGHT bond for the left→right sweep: given the old left-isometry `U₀` (`AL_old`) and -# the evolved candidate `K₁`, build `Û = [U₀ │ Ũ₁]` (`Vl⊗P ← Vr₀ ⊕ Vr_new`) whose column space -# contains both `range(U₀)` and `range(K₁)`, keeping the old basis as the leading per-sector block -# (no truncation). Returns `(Û, M)` with `M = Û' U₀ = [𝟙; 0]` the old bond's coordinates in `Û`. +# Augment the RIGHT bond for the left→right sweep: orthonormalize the stacked `[U₀ │ K₁]` (the old +# left-isometry `U₀` first, then the evolved candidate `K₁`) with a single QR. This keeps `U₀` as +# the leading per-sector block and appends only the directions of `K₁` orthogonal to it, while the +# QR caps each charge sector at `dim(Vl⊗P, c)` so no direction outside `Vl⊗P` is ever added. function _bug_augment_left(U₀, K₁, alg_orth = Defaults.alg_orth()) - N = left_null(U₀) # Vl⊗P ← Vc, orthonormal complement of range(U₀) - g = N' * K₁ # Vc ← Vr_K, the part of K₁ orthogonal to U₀ - Q, _ = left_orth(g; alg = alg_orth) # Vc ← Vr_new, orthonormal new directions - Ũ₁ = N * Q # Vl⊗P ← Vr_new - Û = catdomain(U₀, Ũ₁) # Vl⊗P ← (Vr₀ ⊕ Vr_new), old-first - M = Û' * U₀ # V̂ ← Vr₀, = [𝟙; 0] per sector - return Û, M + Û, _ = left_orth(catdomain(U₀, K₁); alg = alg_orth) # Vl⊗P ← (Vr₀ ⊕ Vr_new), old-first + return Û end -# Mirror of `_bug_augment_left` for the right→left sweep: augment the LEFT bond on the -# `_transpose_tail` form (`Vl ← P⊗Vr`, in which a right-isometry has orthonormal rows). Given the -# old right-isometry `U₀` (`AR_old`) and candidate `K₁`, returns `(Û, M)` with `Û` right-canonical -# on `V̂ = Vl₀ ⊕ Vl_new` (old-first) and `M = û u₀' = [𝟙; 0]` per sector. +# Mirror of `_bug_augment_left` for the right→left sweep, on the `_transpose_tail` form +# (`Vl ← P⊗Vr`, in which a right-isometry has orthonormal rows): a single LQ orthonormalizes the +# stacked rows `[U₀; K₁]`, keeping `U₀` as the leading block. function _bug_augment_right(U₀, K₁, alg_orth = Defaults.alg_orth()) - u₀ = _transpose_tail(U₀) # Vl₀ ← P⊗Vr, right-isometric (u₀ u₀' = 𝟙) - k₁ = _transpose_tail(K₁) # Vl_K ← P⊗Vr - N = right_null!(_transpose_tail(U₀; copy = true)) # Vc ← P⊗Vr, complement of U₀'s row space - g = k₁ * N' # Vl_K ← Vc, the part of K₁ orthogonal to U₀ - _, Q = right_orth(g; alg = alg_orth) # Q: Vl_new ← Vc, orthonormal new directions - Ũ₁ = Q * N # Vl_new ← P⊗Vr - û = catcodomain(u₀, Ũ₁) # (Vl₀ ⊕ Vl_new) ← P⊗Vr, old-first - Û = _transpose_front(û) # V̂ ⊗ P ← Vr - M = û * u₀' # V̂ ← Vl₀, = [𝟙; 0] per sector - return Û, M + stacked = catcodomain(_transpose_tail(U₀), _transpose_tail(K₁)) # (Vl₀ ⊕ Vl_K) ← P⊗Vr + _, Û = right_orth(stacked; alg = alg_orth) # V̂ ← P⊗Vr, old-first + return _transpose_front(Û) end # copying version diff --git a/test/algorithms/bug_augment.jl b/test/algorithms/bug_augment.jl index 6e93917cd..947d2c378 100644 --- a/test/algorithms/bug_augment.jl +++ b/test/algorithms/bug_augment.jl @@ -39,7 +39,8 @@ function _check_old_first(M, V_old; tol = 1.0e-10) end function check_augment_left(U₀, K₁; tol = 1.0e-10) - Û, M = _bug_augment_left(U₀, K₁) + Û = _bug_augment_left(U₀, K₁) + M = Û' * U₀ # old bond's coordinates in the augmented basis (V̂ ← Vr₀) # 1. isometry @test Û' * Û ≈ one(Û' * Û) # 2. old-first, per sector (M : V̂ ← Vr₀) @@ -55,10 +56,11 @@ function check_augment_left(U₀, K₁; tol = 1.0e-10) end function check_augment_right(U₀, K₁; tol = 1.0e-10) - Û, M = _bug_augment_right(U₀, K₁) + Û = _bug_augment_right(U₀, K₁) ût = _transpose_tail(Û) # V̂ ← P ⊗ Vr, right-isometric (row space) u0t = _transpose_tail(U₀) k1t = _transpose_tail(K₁) + M = ût * u0t' # old bond's coordinates in the augmented basis (V̂ ← Vl₀) # 1. isometry (right-canonical ⇒ tail has orthonormal rows) @test ût * ût' ≈ one(ût * ût') # 2. old-first, per sector (M : V̂ ← Vl₀) From b17f517974855b28001dde8f19f22d5c49c51a1e Mon Sep 17 00:00:00 2001 From: lkdvos Date: Wed, 8 Jul 2026 23:22:42 -0400 Subject: [PATCH 12/23] BUG: ParallelBUG integrator (Stage 3, experimental/WIP) Add a `ParallelBUG <: Algorithm` for `FiniteMPS`: the parallel Basis-Update & Galerkin integrator (Ceruti et al. 2024) specialized to the caterpillar tree. All local problems read from one frozen `t0` snapshot (no sweep); the root carries the amplitude, interior connecting tensors are evolved as isometries, bonds are augmented old-first with a zeroed-corner coupling block, then cut back by an SvdCut sweep. Experimental. Verified behaviour (test/algorithms/parallelbug.jl, 37 passing): - exact reproduction of the dense exp(-iH dt) step at two sites; - energy + eigenstate-phase conservation (amplitude carried once, at the root); - agreement with TDVP over short times; - monotone imaginary-time energy lowering, norm-preserving; - adaptive bond growth under a tight `trscheme`; - U(1)/Z2 total-charge and graded-structure preservation. Known gap (2 `@test_broken`): the documented first-order accuracy is not yet attained for L > 2 (measured slope ~0). The coupling-block reconciliation (M = U'U0, Ceruti et al. Alg. 4) is incomplete; the diagnosis and the path to close it are in research/PARALLELBUG_{design,STATUS}.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- research/PARALLELBUG_STATUS.md | 71 +++++++++ research/PARALLELBUG_design.md | 168 +++++++++++++++++++ src/MPSKit.jl | 3 +- src/algorithms/timestep/parallelbug.jl | 204 +++++++++++++++++++++++ test/algorithms/parallelbug.jl | 213 +++++++++++++++++++++++++ 5 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 research/PARALLELBUG_STATUS.md create mode 100644 research/PARALLELBUG_design.md create mode 100644 src/algorithms/timestep/parallelbug.jl create mode 100644 test/algorithms/parallelbug.jl diff --git a/research/PARALLELBUG_STATUS.md b/research/PARALLELBUG_STATUS.md new file mode 100644 index 000000000..05c2c3bfa --- /dev/null +++ b/research/PARALLELBUG_STATUS.md @@ -0,0 +1,71 @@ +# `ParallelBUG` implementation status + +Companion to `PARALLELBUG_design.md`. Records what is implemented, what works, the one open +problem, and the recommended path to close it. **The integrator is experimental (WIP).** + +## What is implemented (branch `ld-parallelbug`) + +* `ParallelBUG <: Algorithm` struct + kw-constructor + docstring, registered and exported + (`src/algorithms/timestep/parallelbug.jl`). +* A `timestep!` + copying `timestep` implementing the **frozen-`t₀` assembly** (the paper's + Alg. 1–4 specialized to the caterpillar): one frozen snapshot `ψ₀ = copy(ψ)` + `envs₀`; the root + center `AC[L]` and the interior isometries `AL[i]` are each evolved forward from that snapshot; + bonds are augmented old-first with the new directions the interior evolutions discover, coupled by + first-order blocks with zeroed multi-new corners; then an `SvdCut` sweep truncates. +* `test/algorithms/parallelbug.jl` covering the behaviours below. + +## What works (asserted in the tests) + +* **2-site: exact.** Reproduces the dense `exp(-iH·dt)` step to ~1e-12 (the matrix parallel-BUG + formulas are exact here — this locks the block conventions / adjoints). +* **Energy + eigenstate phase: exact.** `angle⟨ψ₁|ψ₀⟩/(dt·E₀) = 1` for `L = 2,3,4` (amplitude is + carried once, at the root), `|E₁−E₀| ~ 1e-15`. +* **Rank adaptivity: bonds grow.** A low-bond-dim start under a tight `truncerror` grows the bond + (e.g. 2 → 8) via the augment-then-`SvdCut` mechanism. +* Imaginary-time renormalization and symmetric-tensor (U(1)) total-charge preservation — see the + test file for the exact assertions that hold. + +## The one open problem (marked `@test_broken`) + +For `L > 2` the integrator does **not** yet attain the documented **first-order** accuracy: the +error does not decrease cleanly `∝ dt`, and tightening `ϑ` does not monotonically improve the +overlap with the dense reference. Root cause and analysis: + +MPSKit's `AC_hamiltonian`/`C_hamiltonian` are **full-environment** effective Hamiltonians (each +carries the full energy `E₀`). This forces a trilemma when mapping the caterpillar (verified +numerically across three assemblies): + +| assembly | interior "old" block | phase ratio | order | +|---|---|---|---| +| A: keep evolved connecting tensor as a block | evolved `C̄¹_i` (amplitude kept) | ≈ `L` (overcounts) | — | +| B: freeze the basis, couple new dirs | frozen `AL[i]` (phase stripped) | **1** ✓ | ~0 (shipped) | +| C: symmetric `AC`+`C` regauge | evolved `AC`+`C` daggered | **1** ✓, exact @ L=2 | 2 (parallel-TDVP, not BUG) | + +The shipped driver is **assembly B**: it strips the interior phase correctly (ratio 1) and grows +bonds, but the first-order dynamics are not fully captured because the coupling-block +**reconciliation** is incomplete. Specifically, the paper's Alg. 4 builds the augmented basis from +the range of *both* the reprojected old connecting tensor `Ĉ⁰ = C⁰ ×_child Û'U⁰` **and** the +Galerkin-evolved `Ĉ¹`, and threads the overlap `M = Û'U₀` up toward the root. The shipped assembly +places the coupling directly in the new-child rows without that `M`-reconciliation, so the retained +subspace after `SvdCut` is not rotated by the correct `O(dt)` amount — hence the loss of clean first +order. (The `AC2_hamiltonian`-based coupling used for `C̃_i` is also a first suspect; the correct +one-site term is `dt·(AC_hamiltonian(i)·AC[i])` projected onto the new directions.) + +## Recommended path to close it + +1. Implement the leaves→root **`M = Û'U₀` reconciliation** (design doc §2b / paper Alg. 4A.2–A.4): + at each interior node reproject the *old* connecting tensor onto the already-augmented child + basis and build the parent-bond basis from the range of `[Mat₀(Ĉ⁰); Mat₀(Ĉ¹)]`, threading the + overlap upward — rather than the direct block placement used now. +2. Replace the two-site `AC2_hamiltonian` coupling with the one-site `dt·(AC_hamiltonian(i)·AC[i])` + projected onto `Ũ₁` (matrix eq. 3.1a analog). +3. Re-run `research/`'s gate harness (2-site exact / phase ratio / **slope ≈ 1** / **accuracy + improves with ϑ** / bond growth / symmetric preservation). The scaffolding and tests are ready; + flip the `@test_broken`s to `@test` once slope ≈ 1 holds. +4. Only then add step rejection (`η = ‖Ũ₁*F₀Ṽ₁‖`, `hη>cϑ`) and the `@sync`/`tmap!` threading (the + K-steps already read from one frozen snapshot, so threading is a localized change). + +Nothing here requires new MPSKit primitives — the earlier "need partial-energy node operators" +hypothesis is not necessary: the tree algorithm handles the full-energy operators by +*orthonormalizing* the interior evolved tensors (stripping their phase), which assembly B already +does; the remaining work is the coupling reconciliation, not the effective operators. diff --git a/research/PARALLELBUG_design.md b/research/PARALLELBUG_design.md new file mode 100644 index 000000000..b85243e20 --- /dev/null +++ b/research/PARALLELBUG_design.md @@ -0,0 +1,168 @@ +# Design: `ParallelBUG` integrator for `FiniteMPS` in MPSKit + +Stage 3 of the BUG family (see `BUG_finiteMPS_design.md` §7, which left this "not yet pinned +down"). This note **derives** the concrete MPS-level recipe from the two 2024 parallel papers and +records the design decisions for a `ParallelBUG <: Algorithm`. + +Written on branch `ld-parallelbug` (worktree), branched off `ld-bug` (which carries the sequential +fixed-rank + rank-adaptive `BUG`). + +## 1. Source algorithm (transcribed from the papers) + +References in `research/`: +* Ceruti, Kusch, Lubich 2024, *A Parallel Rank-Adaptive Integrator for DLRA*, SISC 46(3) — the **matrix** case (the block formulas below). +* Ceruti, Kusch, Lubich, Sulz 2024, *A parallel BUG Integrator for Tree Tensor Networks*, arXiv:2412.00858 — the **tree** generalization (Alg. 1–4). **Has no MPS section**; the caterpillar specialization here is our derivation. + +### 1.1 Matrix parallel BUG (the load-bearing block structure) + +Start from `Y₀ = U₀ S₀ V₀*`, rank `r`. One step `t₀ → t₁ = t₀ + h`. **Three independent forward +ODEs, all from `t₀` data, all mutually parallel:** + +* **K-step** (evolve the left factor + core, right factor frozen): + `K̇ = F(t, K V₀*) V₀`, `K(t₀) = U₀ S₀`. Then `[U₀ | K(t₁)]` QR → `Û = [U₀ | Ũ₁]` (old-first). +* **L-step** (evolve the right factor + core, left factor frozen): + `L̇ = F(t, U₀ L*)* U₀`, `L(t₀) = V₀ S₀*`. Then `[V₀ | L(t₁)]` QR → `V̂ = [V₀ | Ṽ₁]`. +* **S-step** (evolve the core, BOTH factors frozen — this is the *parallel* twist; sequential BUG + would use the augmented bases here, forcing a `2r×2r` solve after K/L): + `Ṡ̄ = U₀* F(t, U₀ S̄ V₀*) V₀`, `S̄(t₀) = S₀`. Size stays `r×r`. + +**Augmented core (eq. 3.4)** — assembled *algebraically*, no extra ODE: +``` + old V₀ new Ṽ₁ + ┌───────────┬──────────┐ +old U₀│ S̄(t₁) │ S̃ᴸ │ S̃ᴷ = Ũ₁* K(t₁) (K coupling, bottom-left) + ├───────────┼──────────┤ S̃ᴸ = L(t₁)* Ṽ₁ (L coupling, top-right) +new Ũ₁│ S̃ᴷ │ 0 │ new–new corner = 0 ⇒ FIRST ORDER + └───────────┴──────────┘ +``` +Discarding the `0` corner costs `O(h² + hε)` local error — this is exactly why parallel BUG is +**first order** (sequential BUG keeps that block and is second order). Truncate `Ŝ₁` by SVD to +tolerance `ϑ` → new `U₁, V₁, S₁`. + +**Step rejection (§3.3):** normal-component estimator `η = ‖Ũ₁* F(t₀, Y₀) Ṽ₁‖` (= the discarded +corner / `h`). Reject & recompute the step on the *augmented* bases `Û, V̂` if either +(a) `r₁ = 2r` (truncation saturated the doubling cap), or (b) `h·η > c·ϑ` (`c ≈ 10`). + +### 1.2 Tree/MPS generalization + +Per node `τ` with children `τᵢ`: Galerkin-evolve the connecting tensor with **all surrounding bases +frozen at `t₀`** (Alg. 3); build each bond's new directions `Ũ¹_{τᵢ}` old-first (Alg. 4A); assemble +the augmented connecting tensor by **stacking coupling blocks mode-by-mode** (Alg. 4B): +``` +Ĉ ← C̄¹; for each child mode i: Ĉ ← Tenᵢ( [ Matᵢ(Ĉ) ; Matᵢ(C̃ᵢ) ] ) +C̃ᵢ = h · F_τ(Y₀) ×_{j≠i} U₀*_{τⱼ} ×ᵢ Ũ¹*_{τᵢ} (all multi-new corners left at 0) +``` +Robust first-order global bound (Thm 4.5): `‖Yₖ − A(tₖ)‖ ≤ c₁h + c₂ε + c₃δ + c₄·k·ϑ`, constants +independent of the bond singular values. Truncation term `c₄·k·ϑ` ⇒ scale `ϑ ∝ h`. + +## 2. MPS specialization (caterpillar rooted at site L) + +`FiniteMPS` = linear tree, rooted at site `L` (left-canonical: `AL[1..L-1]`, center at `L`). Physical +legs are **uncompressed leaves** (`U = 𝟙`), so they are never augmented — **only the `L-1` virtual +bonds grow.** Treat each **bond `b`** (between sites `b`, `b+1`) as the matrix-DLRA object: + +| matrix object | MPS object at bond `b` | MPSKit primitive | +|---|---|---| +| `U₀` (left factor) | left block `AL[1..b]`, locally `AL[b]` | — | +| `V₀` (right factor) | right block `AR[b+1..L]`, locally `AR[b+1]` | — | +| `S₀` (core) | bond tensor `C[b]` | — | +| K-step `K(t₁)` | evolve site `b`: `AC_hamiltonian(b)` on `AC[b]`, frozen `t₀` | `integrate` | +| L-step `L(t₁)` | evolve site `b+1`: `AC_hamiltonian(b+1)` on `AC[b+1]`, frozen `t₀` | `integrate` | +| S-step `S̄(t₁)` | evolve bond `b`: `C_hamiltonian(b)` on `C[b]`, frozen `t₀` | `integrate` | +| `Ũ₁` (new left dirs) | `_bug_augment_left(AL[b], K(t₁))` (already in `bug.jl`) | `left_null`/`catdomain` | +| `Ṽ₁` (new right dirs) | `_bug_augment_right(AR[b+1], L(t₁))` (already in `bug.jl`) | `right_null`/`catcodomain` | + +This reuses **exactly** the effective operators (`AC_hamiltonian`, `C_hamiltonian`, `integrate`) and +augmentation helpers (`_bug_augment_left/right`) already present — the only genuinely new pieces are +the frozen-`t₀` scheduling, the `2×2` core assembly, and step rejection. + +**All local ODEs read from one frozen snapshot** `(ψ₀ = copy(ψ), envs₀ = environments(ψ₀, H, ψ₀))`, +so every `AC_hamiltonian(i)`/`C_hamiltonian(b)` is well-defined simultaneously (identical to how the +`InfiniteMPS` `timestep` in `tdvp.jl` evolves all `AC`/`C` from one frozen `envs`). This is the +parallelizable structure — reuse that file's `@sync`/`Threads.@spawn` + `tmap!` gated on +`Defaults.scheduler[]`. + +## 2b. CORRECTION (implementation finding, must supersede §2's per-bond picture) + +The per-bond matrix-DLRA mapping in §2 (an S-step via `C_hamiltonian(b)` on **every** bond) is +**wrong for MPS** and was falsified in implementation: + +* **Phase overcounting.** MPSKit's `C_hamiltonian(b)` is the *full-environment* effective + Hamiltonian, so each bond's S-step carries the full energy phase `exp(-iE·dt)`. With a core on + every one of the `L-1` bonds these phases **compose**, giving `exp(-i(L-1)E·dt)` — the eigenstate + phase is overcounted by exactly `(L-1)×` (measured: ratio 2.0 at `L=3`, 3.0 at `L=4`). There is no + backward substep to cancel it (unlike TDVP). +* **No bond growth.** Augmenting from the *frozen-evolved `AC[i]`* cannot grow a bond, because + `range(AC[i]) = range(AL[i])` up to the evolution, and worse, `_bug_augment_left(AL[i], AC[i])` + extracts `g = N'·AC` which is ~0. + +**The correct MPS mapping is the literal tree recursion, not L−1 independent matrix problems:** + +* The caterpillar rooted at `L` is **one** tree. Only the **root** connecting tensor (`AC[L]`) + carries amplitude/phase; every interior connecting tensor is an **isometry** (`AL[i]`). So there is + **no per-bond `C_hamiltonian` S-step at all** — the amplitude is evolved **once**, at the root. +* Interior nodes `i c·ϑ` threshold. With `notrunc()` (fixed rank) the + rank-saturation trigger is disabled (we deliberately cut back to the old rank). Rejection is a + genuine-adaptivity feature; keep it simple and cheap (`η` from one frozen `F₀` apply). + +## 4. Build plan (small steps, each validated) + +1. **Struct + registration.** `ParallelBUG` mirroring `BUG` fields (+`c`, `maxiter_rejection`); kw + constructor; `include` + export. No logic yet. Sanity: `ParallelBUG()` constructs. +2. **Serial driver, no rejection.** `timestep!` (+ copying `timestep`): frozen snapshot → per-site + `AC_hamiltonian` + per-bond `C_hamiltonian` local solves → per-bond `2×2` augmented assembly → + `SvdCut` truncation. Serial scheduler first (correctness before parallelism). + Gate: energy conservation on a ground state; first-order slope; TDVP agreement; dense overlap. +3. **Imaginary time + norm.** Renormalize after truncation; monotone energy-lowering test. +4. **Symmetric tensors.** U(1)/Z2/SU(2) sector + charge preservation, dynamic grading (reuse + `bug.jl`'s symmetric test scaffold). +5. **Step rejection.** `η` estimator + recompute loop; a test that a rank-1 start grows past a single + doubling within one step via rejection. +6. **Parallelism.** Swap the per-site/per-bond loops onto `@sync`/`Threads.@spawn` + `tmap!` gated on + `Defaults.scheduler[]` (mirror `tdvp.jl`'s `InfiniteMPS` branch). Gate: identical results to the + serial path (scheduler must not change the answer). + +## 5. Risk register (inherits `BUG_finiteMPS_design.md` §9 H1–H10, plus) + +| # | Risk | Mitigation | +|---|---|---| +| P1 | MPS assembly of the `2×2` block across *both* bonds of each site is subtle (each bond has one new space, from its left neighbor); easy to double-count the core (cf. §3 sequential warning) | Behavioral gate (§3.5): first-order slope + dense overlap will expose a wrong assembly; build the 2-site case first where it reduces to the exact matrix formulas | +| P2 | New-new corner must be *exactly* zero (first-order); a stray coupling makes it inconsistent | Assemble with explicit `zerovector!` block; assert bond dims double before truncation | +| P3 | Thread safety of shared frozen graded snapshot (H9) | Genuine `copy`; read-only `envs₀`; follow `tdvp.jl` infinite threading structure; no shared per-sector buffers | +| P4 | `ϑ` accumulates as `c₄·k·ϑ`; users expect TDVP-like accuracy | Document `ϑ ∝ h`; note first-order in docstring like `BUG`'s note | diff --git a/src/MPSKit.jl b/src/MPSKit.jl index 3917bca2b..687928791 100644 --- a/src/MPSKit.jl +++ b/src/MPSKit.jl @@ -35,7 +35,7 @@ export VUMPS, VOMPS, DMRG, DMRG2, IDMRG, IDMRG2, GradientGrassmann export excitations export FiniteExcited, QuasiparticleAnsatz, ChepigaAnsatz, ChepigaAnsatz2 export time_evolve, timestep, timestep!, make_time_mpo -export TDVP, TDVP2, BUG, WI, WII, TaylorCluster +export TDVP, TDVP2, BUG, ParallelBUG, WI, WII, TaylorCluster export changebonds, changebonds! export VUMPSSvdCut, OptimalExpand, SvdCut, RandExpand, SketchedExpand export propagator @@ -162,6 +162,7 @@ include("algorithms/changebonds/sketchedexpand.jl") include("algorithms/timestep/tdvp.jl") include("algorithms/timestep/bug.jl") +include("algorithms/timestep/parallelbug.jl") include("algorithms/timestep/taylorcluster.jl") include("algorithms/timestep/wii.jl") include("algorithms/timestep/integrators.jl") diff --git a/src/algorithms/timestep/parallelbug.jl b/src/algorithms/timestep/parallelbug.jl new file mode 100644 index 000000000..6e77262a7 --- /dev/null +++ b/src/algorithms/timestep/parallelbug.jl @@ -0,0 +1,204 @@ +""" +$(TYPEDEF) + +Single site MPS time-evolution algorithm based on the *parallel* Basis-Update & Galerkin (BUG) +integrator for tree tensor networks, specialized to the linear (`FiniteMPS`) tree. + +Unlike the sequential [`BUG`](@ref), every local problem is solved from the **same frozen `t₀` +snapshot** (`ψ₀ = copy(ψ)` and its environments): there is no sweep and no sequential dependency +between the local integrations, so they are mutually independent and parallelizable. Rooting the +caterpillar at the last site, the amplitude is carried by the root center `AC[L]` while the interior +connecting tensors are *isometries* `AL[i]`; each is evolved forward from `t₀`. Every bond is then +augmented with the new directions discovered by the frozen evolution (`[U₀ │ Ũ₁]`, old basis first) +together with a first-order coupling block, and truncated back down by an SVD sweep. Like +[`BUG`](@ref), it advances every tensor *forward* in time (no backward substep), which suits +imaginary-time / dissipative evolution. + +The truncation tolerance of `trscheme` maps to the BUG tolerance `ϑ`; because the global error +accumulates as `c·n·ϑ` over `n` steps, scale `ϑ` with `dt` for a fixed target accuracy. Any +truncating `trscheme` (e.g. `truncerror`) makes the bond dimension grow and shrink to track the +entanglement of the evolving state. + +!!! warning "Experimental" + This integrator is **work in progress**. It reproduces the exact matrix parallel-BUG step for + two sites, conserves energy / the eigenstate phase exactly, and grows bonds adaptively, but the + coupling-block reconciliation (the `M = Û'U₀` reprojection of Ceruti et al. 2024, Alg. 4) is not + yet complete, so it does **not** yet attain the documented first-order accuracy for `L > 2`. See + `research/PARALLELBUG_design.md` and `research/PARALLELBUG_STATUS.md`. The API and behaviour may + change. + +!!! note + Real-time evolution does not normalize the resulting state: neither the augmentation nor the + truncation normalizes, so the state norm reflects the accumulated truncation error. + Imaginary-time evolution renormalizes after every step, similar to a ground-state search. + +## Fields + +$(TYPEDFIELDS) + +## References + +* Ceruti, Kusch & Lubich, *A parallel rank-adaptive integrator for dynamical low-rank + approximation*, SIAM J. Sci. Comput. **46** (2024). +* Ceruti, Kusch, Lubich & Sulz, *A parallel Basis Update and Galerkin integrator for tree tensor + networks*, arXiv:2412.00858 (2024). +""" +struct ParallelBUG{A, O, T, S, F} <: Algorithm + "algorithm used in the exponential solvers" + integrator::A + + "tolerance for gauging algorithm" + tolgauge::Float64 + + "maximal amount of iterations for gauging algorithm" + gaugemaxiter::Int + + "algorithm used to re-orthonormalize the basis after each local update" + alg_orth::O + + "truncation scheme used to cut the augmented bonds back down" + trscheme::T + + "algorithm used for the singular value decomposition" + alg_svd::S + + "callback function applied after each iteration, of signature `finalize(iter, ψ, H, envs) -> ψ, envs`" + finalize::F +end +function ParallelBUG(; + integrator = Defaults.alg_expsolve(), tolgauge = Defaults.tolgauge, + gaugemaxiter = Defaults.maxiter, alg_orth = Defaults.alg_orth(), + trscheme = notrunc(), alg_svd = Defaults.alg_svd(), + finalize = Defaults._finalize + ) + return ParallelBUG(integrator, tolgauge, gaugemaxiter, alg_orth, trscheme, alg_svd, finalize) +end + +function timestep!( + ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::ParallelBUG, + envs::AbstractMPSEnvironments = environments(ψ, H, ψ); + imaginary_evolution::Bool = false + ) + L = length(ψ) + if L == 1 # single site: a plain forward center step + AC = integrate( + AC_hamiltonian(1, ψ, H, ψ, envs), ψ.AC[1], t, dt, alg.integrator; imaginary_evolution + ) + imaginary_evolution && normalize!(AC) + ψ.AC[1] = AC + return ψ, envs + end + + # remember the pre-step bond dimensions for the fixed-rank (`notrunc`) restore + Ds = [dim(right_virtualspace(ψ, b)) for b in 1:(L - 1)] + + ϕ = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) + _pbug_truncate!(ϕ, alg, Ds; normalize = imaginary_evolution) + + # mutate `ψ` in place to become the assembled state (the generic loop reuses the object) + _pbug_overwrite!(ψ, ϕ) + return ψ, environments(ψ, H, ψ) +end + +# Assemble the augmented (pre-truncation) parallel-BUG state from a single frozen `t₀` snapshot. +# Root the caterpillar at site `L`; only the root center `AC[L]` carries amplitude, the interior +# connecting tensors are the isometries `AL[i]`. Each is evolved forward from the frozen snapshot; +# the interior evolutions define the new bond directions `Ũ`, and first-order coupling blocks connect +# them (the "new–new" corners are left at zero — this is what makes the integrator first order). +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) + L = length(ψ) + ψ.AC[L] # gauge to the root + ψ₀ = copy(ψ) + envs₀ = environments(ψ₀, H, ψ₀) + dt′ = imaginary_evolution ? -dt : -im * dt + + # interior isometry evolutions → new bond directions `Ũ_i` on bond `i` + Us = Vector{Any}(undef, L - 1) + Ns = Vector{Any}(undef, L - 1) + for i in 1:(L - 1) + Cevo = integrate( + AC_hamiltonian(i, ψ₀, H, ψ₀, envs₀), ψ₀.AL[i], t, dt, alg.integrator; imaginary_evolution + ) + Us[i], Ns[i] = _pbug_newdirs(ψ₀.AL[i], Cevo, alg.alg_orth) + end + # root center evolution (carries the amplitude / eigenstate phase, once) + CbarL = integrate( + AC_hamiltonian(L, ψ₀, H, ψ₀, envs₀), ψ₀.AC[L], t, dt, alg.integrator; imaginary_evolution + ) + + # first-order coupling blocks C̃_i : Ñ_{i-1} ⊗ P_i ← V_i, from the frozen two-site derivative + Ctil = Vector{Any}(undef, L) + for i in 2:L + T2 = _transpose_front(ψ₀.AL[i - 1]) * _transpose_tail(ψ₀.AC[i]) + F2 = AC2_hamiltonian(i - 1, ψ₀, H, ψ₀, envs₀) * T2 + Ctil[i] = scale(_transpose_front(Us[i - 1]' * F2), dt′) + end + + # stack the augmented site tensors (old block first, coupling in the new-child rows, zero corner) + As = Vector{Any}(undef, L) + As[1] = catdomain(copy(ψ₀.AL[1]), Us[1]) + for i in 2:(L - 1) + top = catdomain(copy(ψ₀.AL[i]), Us[i]) + Z = zerovector!(similar(Ctil[i], codomain(Ctil[i]) ← Ns[i])) + bot = catdomain(Ctil[i], Z) + As[i] = _pbug_stack_child(top, bot) + end + As[L] = _pbug_stack_child(CbarL, Ctil[L]) + + return FiniteMPS( + convert(Vector{typeof(As[1])}, As); overwrite = true, normalize = imaginary_evolution + ) +end + +# new bond directions: the component of the evolved (interior) isometry orthogonal to the old basis, +# re-orthonormalized. Returns the isometry `Ũ` and the new bond space. +function _pbug_newdirs(AL, Cevo, alg_orth = Defaults.alg_orth()) + N = left_null(AL) + g = N' * Cevo + Q, _ = left_orth(g; alg = alg_orth) + Ũ = N * Q + return Ũ, domain(Q) +end + +# stack two MPS tensors along the child (left-virtual) bond: `[top; bot]`, doubling that bond +_pbug_stack_child(top, bot) = + _transpose_front(catcodomain(_transpose_tail(top), _transpose_tail(bot))) + +_pbug_truncates(alg::ParallelBUG) = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) + +# Cut the augmented bonds back down. A truncating `trscheme` selects rank-adaptivity; the default +# `notrunc()` restores the pre-step bond dimensions (fixed-rank parallel BUG). Environments +# self-heal lazily for the changed bonds. +function _pbug_truncate!(ϕ, alg::ParallelBUG, Ds; normalize::Bool = false) + trscheme = _pbug_truncates(alg) ? alg.trscheme : truncrank(maximum(Ds)) + changebonds!(ϕ, SvdCut(; trscheme, alg_svd = alg.alg_svd); normalize) + return ϕ +end + +# overwrite the internal representation of `ψ` with that of `ϕ` (same length, possibly new bonds) +function _pbug_overwrite!(ψ::FiniteMPS, ϕ::FiniteMPS) + for f in (:ALs, :ARs, :ACs, :Cs) + copyto!(getfield(ψ, f), getfield(ϕ, f)) + end + return ψ +end + +# copying version (mirrors `bug.jl`/`tdvp.jl`) +function timestep( + ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, + alg::ParallelBUG, envs::AbstractMPSEnvironments...; + imaginary_evolution::Bool = false, kwargs... + ) + isreal = (scalartype(ψ) <: Real && !imaginary_evolution) + ψ′ = isreal ? complex(ψ) : copy(ψ) + if length(envs) != 0 && isreal + @warn "Currently cannot reuse real environments for complex evolution" + envs′ = environments(ψ′, H, ψ′) + elseif length(envs) == 1 + envs′ = only(envs) + else + @assert length(envs) == 0 "Invalid signature" + envs′ = environments(ψ′, H, ψ′) + end + return timestep!(ψ′, H, time, timestep, alg, envs′; imaginary_evolution, kwargs...) +end diff --git a/test/algorithms/parallelbug.jl b/test/algorithms/parallelbug.jl new file mode 100644 index 000000000..42f521ff5 --- /dev/null +++ b/test/algorithms/parallelbug.jl @@ -0,0 +1,213 @@ +println(" +------------------------------------- +| ParallelBUG time-stepping tests | +------------------------------------- +") + +using .TestSetup +using Test, TestExtras +using MPSKit +using TensorKit +using TensorKit: ℙ +using LinearAlgebra: dot, norm +using Random + +# NOTE (experimental / WIP). `ParallelBUG` is the parallel Basis-Update & Galerkin integrator +# (Ceruti et al. 2024) specialized to the caterpillar `FiniteMPS`. It reproduces the exact matrix +# parallel-BUG step for two sites, conserves energy / the eigenstate phase exactly (amplitude carried +# once, at the root), grows bonds adaptively, and agrees with `TDVP` over short times. Its documented +# *first-order* accuracy for `L > 2` is NOT yet attained — the coupling-block reconciliation +# (`M = Û'U₀`, Ceruti et al. Alg. 4) is incomplete — so the two accuracy/order gates below are +# `@test_broken` and flip to `@test` once that lands. See `research/PARALLELBUG_STATUS.md`. +@testset "ParallelBUG time evolution" verbose = true begin + dt = 0.1 + L = 6 + + H = force_planar(heisenberg_XXX(Float64, Trivial; spin = 1 // 2, L)) + ψ = FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + + # 1. energy conservation + eigenstate phase (amplitude is carried exactly once, at the root) + @testset "energy conservation + eigenstate phase" begin + ψ1, envs = timestep(ψ₀, H, 0.0, dt, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-12))) + E1 = expectation_value(ψ1, H, envs) + @test E₀ ≈ E1 atol = 1.0e-2 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + end + + # 2. two sites: exact reproduction of the dense exp(-iH·dt) step (locks the block conventions) + @testset "two-site exactness vs dense reference" begin + Random.seed!(2) + Lc = 2 + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) + ψc = FiniteMPS(rand, ComplexF64, Lc, ℙ^2, ℙ^4) + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψc); ψvec /= norm(ψvec) + ref = exp(-im * Hmat * 0.05) * ψvec + ψ1, = timestep(ψc, Hc, 0.0, 0.05, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-12))) + out = convert(TensorMap, ψ1); out /= norm(out) + @test 1 - abs(dot(out, ref)) < 1.0e-10 + end + + # 3. agreement with TDVP over a few short real-time steps of a random MPS + @testset "agreement with TDVP" begin + Random.seed!(1234) + ψr = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4)) + δt = 0.01 + ψ_p, ψ_tdvp = ψr, ψr + for k in 0:4 + ψ_p, = timestep(ψ_p, H, k * δt, δt, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-12))) + ψ_tdvp, = timestep(ψ_tdvp, H, k * δt, δt, TDVP()) + end + @test expectation_value(ψ_p, H) ≈ expectation_value(ψ_tdvp, H) atol = 1.0e-3 + @test abs(dot(ψ_p, ψ_tdvp)) ≈ 1 atol = 1.0e-3 + end + + # 4. imaginary-time evolution lowers the energy monotonically and stays normalized + @testset "imaginary-time lowers energy" begin + Random.seed!(5) + ψi = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4)) + E_start = real(expectation_value(ψi, H)) + E_prev = E_start + for _ in 1:20 + ψi, = timestep( + ψi, H, 0.0, 0.1, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-10)); + imaginary_evolution = true + ) + E_now = real(expectation_value(ψi, H)) + @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy + E_prev = E_now + end + @test E_prev < E_start - 1.0 # substantial lowering toward the ground state + @test norm(ψi) ≈ 1 atol = 1.0e-6 # imaginary-time renormalizes each step + end + + # 5. rank adaptivity: a low-bond-dim start grows under a tight tolerance and stays small under a + # loose one (the augment-then-SvdCut mechanism injects the new directions the evolution finds). + @testset "bond growth" begin + Random.seed!(3) + Lg = 6 + Hg = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lg)) + ψg = normalize!(complex(FiniteMPS(rand, Float64, Lg, ℙ^2, ℙ^2))) # bond dim 2 + Dstart = maximum(dim(right_virtualspace(ψg, b)) for b in 1:(Lg - 1)) + + ψtight = ψg + for k in 0:4 + ψtight, = timestep(ψtight, Hg, k * 0.05, 0.05, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-8))) + end + Dtight = maximum(dim(right_virtualspace(ψtight, b)) for b in 1:(Lg - 1)) + + ψloose = ψg + for k in 0:4 + ψloose, = timestep(ψloose, Hg, k * 0.05, 0.05, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-2))) + end + Dloose = maximum(dim(right_virtualspace(ψloose, b)) for b in 1:(Lg - 1)) + + @info "ParallelBUG bond growth" Dstart Dloose Dtight + @test Dtight > Dstart # rank-adaptivity grows the bond + @test Dloose < Dtight # a looser tolerance keeps a smaller bond + end + + # 6. LazySum smoke test + @testset "LazySum" begin + Hlazy = LazySum([3 * H, 1.55 * H, -0.1 * H]) + ψl, envs = timestep(ψ₀, Hlazy, 0.0, dt, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-12))) + E = expectation_value(ψl, Hlazy, envs) + @test (3 + 1.55 - 0.1) * E₀ ≈ E atol = 1.0e-2 + end + + # 7. KNOWN LIMITATION (WIP): the integrator is not yet first order for L > 2. The measured + # log–log slope is ≈ 0 (zeroth order) rather than the target ≈ 1, and the overlap error does + # not improve as the truncation tolerance ϑ shrinks. These are `@test_broken` until the + # coupling-block reconciliation (`research/PARALLELBUG_STATUS.md`) is completed. + @testset "first-order accuracy (KNOWN BROKEN — WIP)" begin + Random.seed!(2) + Lc = 4 + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) + ψf = normalize!(FiniteMPS(rand, ComplexF64, Lc, ℙ^2, ℙ^4)) + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψf); ψvec /= norm(ψvec) + + Tfin = 0.2 + dts = [0.05, 0.025, 0.0125] + errs = map(dts) do δt + n = round(Int, Tfin / δt) + ref = exp(-im * Hmat * (n * δt)) * ψvec + ψc = copy(ψf) + for k in 0:(n - 1) + ψc, = timestep(ψc, Hc, k * δt, δt, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-12))) + end + out = convert(TensorMap, ψc); out /= norm(out) + return 1 - abs(dot(out, ref)) + end + slopes = [ + (log(errs[i + 1]) - log(errs[i])) / (log(dts[i + 1]) - log(dts[i])) + for i in 1:(length(dts) - 1) + ] + @info "ParallelBUG convergence (WIP)" errs slopes + # target: first order (slope ≈ 1). Currently measures ≈ 0. + @test_broken all(s -> isapprox(s, 1; atol = 0.3), slopes) + end + + @testset "accuracy improves as ϑ decreases (KNOWN BROKEN — WIP)" begin + Random.seed!(202) + Lc = 6 + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) + ψ0 = normalize!(complex(FiniteMPS(rand, Float64, Lc, ℙ^2, ℙ^2))) + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψ0); ψvec /= norm(ψvec) + Tfin = 0.2; δt = 0.05; n = round(Int, Tfin / δt) + ref = exp(-im * Hmat * (n * δt)) * ψvec + ϑs = [1.0e-2, 1.0e-4, 1.0e-6] + errs = map(ϑs) do ϑ + ψc = copy(ψ0) + for k in 0:(n - 1) + ψc, = timestep(ψc, Hc, k * δt, δt, ParallelBUG(; trscheme = truncerror(; atol = ϑ))) + end + out = convert(TensorMap, ψc); out /= norm(out) + return 1 - abs(dot(out, ref)) + end + @info "ParallelBUG accuracy vs ϑ (WIP)" ϑs errs + @test_broken errs[end] < errs[1] / 2 + end +end + +# Charge-sector coverage: a fixed-rank symmetric step must preserve the total charge and the graded +# structure of every bond (energy conservation + eigenstate phase carry over from the trivial case). +@testset "ParallelBUG symmetric tensors" verbose = true begin + dt = 0.1 + L = 6 + + @testset "U(1) Heisenberg" begin + Random.seed!(2718) + H = heisenberg_XXX(ComplexF64, U1Irrep; spin = 1 // 2, L) + maxV = MPSKit.max_virtualspaces(physicalspace(H)) + ψ = FiniteMPS(physicalspace(H), maxV[2:(end - 1)]; right = U1Space(0 => 1)) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + Rtot = right_virtualspace(ψ₀, L) + Vl₀ = left_virtualspace.(Ref(ψ₀), 1:L) + + ψ1, envs = timestep(ψ₀, H, 0.0, dt, ParallelBUG()) # fixed-rank (notrunc) + E1 = expectation_value(ψ1, H, envs) + @test E₀ ≈ E1 atol = 1.0e-2 + @test imag(E1) ≈ 0 atol = 1.0e-8 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + @test right_virtualspace(ψ1, L) == Rtot # total boundary charge preserved + @test left_virtualspace.(Ref(ψ1), 1:L) == Vl₀ # graded structure preserved (fixed rank) + end + + @testset "Z2 transverse-field Ising" begin + Random.seed!(161803) + H = transverse_field_ising(ComplexF64, Z2Irrep; g = 1.0, L) + ψ = FiniteMPS(physicalspace(H), Z2Space(0 => 4, 1 => 4)) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + + ψ1, envs = timestep(ψ₀, H, 0.0, dt, ParallelBUG()) + E1 = expectation_value(ψ1, H, envs) + @test E₀ ≈ E1 atol = 1.0e-2 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + end +end From b72a6b40edc0503373287ece051f03bd0455aff7 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Thu, 9 Jul 2026 09:30:17 -0400 Subject: [PATCH 13/23] BUG: first-order ParallelBUG assembly (Alg. 4 reconciliation) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the known first-order accuracy gap of the ParallelBUG integrator for L > 2 (the two accuracy gates were @test_broken, measured slope ~0). Two fixes, both pinned against Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4: * Leaves-to-root reconciliation (Alg. 4): the first-order coupling block C̃ᵢ is stacked into the evolved tensor *before* orthonormalizing the new bond directions, so directions discovered deep in the chain propagate up through the spans. The interior tensors of the augmented state are pure isometries [old | Ũᵢ]; all amplitude and first-order content is carried by the root tensor [C̄_L(t₁); C̃_L]. The previous flat assembly placed the couplings in the (new-row, old-col) blocks, which routes them through the amplitude-carrying root block and suppresses them by the old bond matrix — the interior first-order terms effectively vanished. * Amplitude-weighted kets: the interior Galerkin solves and couplings act on the centers AC[i] (the paper's Y_τ⁰ = U_τ⁰S_τ⁰), not the bare isometries AL[i]; their amplitude/phase is discarded with the R-factor in the orthonormalization, so no overcounting occurs (this resolves the "full-environment effective Hamiltonian trilemma" of the design notes). The couplings are one-site effective derivatives against a mixed left environment (two TransferMatrix applications per site), replacing the two-site AC2_hamiltonian objects; LazySum and MultipliedOperator are supported by mirroring the derivative-operator dispatch. The fixed-rank notrunc() path now restores the pre-step virtual space of every bond (per-bond truncspace) instead of a global truncrank(maximum(D)). Measured convergence slope 1.95-1.98 (assert > 0.8, at least the documented first order); the theta-scaling gate now compares against a theta -> 0 run of the same integrator, isolating the c*n*theta truncation term (5e-4 -> 2.5e-6 -> 1e-15). All 40 tests pass; both @test_broken gates flipped to @test. Still experimental: the frozen-snapshot local solves are executed serially and step rejection is not yet implemented (see research/PARALLELBUG_STATUS.md). Co-Authored-By: Claude Fable 5 --- docs/src/changelog.md | 5 + research/PARALLELBUG_STATUS.md | 112 ++++++++-------- research/PARALLELBUG_design.md | 25 ++-- src/algorithms/timestep/parallelbug.jl | 175 +++++++++++++++++-------- test/algorithms/parallelbug.jl | 48 ++++--- 5 files changed, 218 insertions(+), 147 deletions(-) diff --git a/docs/src/changelog.md b/docs/src/changelog.md index c1bc75339..3fe4dbfbd 100644 --- a/docs/src/changelog.md +++ b/docs/src/changelog.md @@ -25,6 +25,11 @@ When releasing a new version, move the "Unreleased" changes to a new version sec finite MPS. Unlike `TDVP` it has no backward-in-time substep (stable for imaginary-time evolution), and passing a truncating `trscheme` enables rank-adaptivity (the bond dimension grows and shrinks automatically to track entanglement). +- `ParallelBUG` time-evolution algorithm (experimental): the first-order *parallel* Basis-Update & + Galerkin integrator for finite MPS, in which every local problem is solved from the same frozen + snapshot of the state — there is no sweep, so the local integrations are mutually independent. + It is intrinsically rank-adaptive (every bond is augmented and then truncated back down); the + default `notrunc()` restores the pre-step virtual spaces (fixed-rank variant). ### Changed diff --git a/research/PARALLELBUG_STATUS.md b/research/PARALLELBUG_STATUS.md index 05c2c3bfa..074a31890 100644 --- a/research/PARALLELBUG_STATUS.md +++ b/research/PARALLELBUG_STATUS.md @@ -1,71 +1,65 @@ # `ParallelBUG` implementation status -Companion to `PARALLELBUG_design.md`. Records what is implemented, what works, the one open -problem, and the recommended path to close it. **The integrator is experimental (WIP).** +Companion to `PARALLELBUG_design.md`. Records what is implemented and what remains. +**The integrator works** (first-order gates pass); it remains marked experimental because the +local solves are still executed serially and step rejection is not implemented. ## What is implemented (branch `ld-parallelbug`) * `ParallelBUG <: Algorithm` struct + kw-constructor + docstring, registered and exported (`src/algorithms/timestep/parallelbug.jl`). -* A `timestep!` + copying `timestep` implementing the **frozen-`t₀` assembly** (the paper's - Alg. 1–4 specialized to the caterpillar): one frozen snapshot `ψ₀ = copy(ψ)` + `envs₀`; the root - center `AC[L]` and the interior isometries `AL[i]` are each evolved forward from that snapshot; - bonds are augmented old-first with the new directions the interior evolutions discover, coupled by - first-order blocks with zeroed multi-new corners; then an `SvdCut` sweep truncates. -* `test/algorithms/parallelbug.jl` covering the behaviours below. +* A `timestep!` + copying `timestep` implementing Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1–4 + specialized to the caterpillar tree rooted at site `L`, in two phases: + 1. **Frozen-snapshot Galerkin evolutions** (Alg. 2/3): one frozen `ψ₀ = copy(ψ)` + `envs₀`; the + interior amplitude-weighted centers `AC[i]` (the paper's `Y_τ⁰ = U_τ⁰S_τ⁰`) and the root + center `AC[L]` are each evolved forward from that snapshot. These `L` local solves are + mutually independent — the parallel-in-time structure (currently executed serially). + 2. **Leaves→root augmentation** (Alg. 4): at bond `i` the new directions `Ũᵢ` are orthonormalized + against the zero-padded old isometry `[AL⁰ᵢ; 0]` from the evolved center *stacked with the + first-order coupling block* `C̃ᵢ` on the new rows of bond `i-1`. The couplings are one-site + effective derivatives with the **mixed** left environment `⟨Ũ-chain|H|AL⁰-chain⟩` (maintained + with two `TransferMatrix` applications per site) and the frozen old right environment. The + interior site tensors of the augmented state are the isometries `[old │ Ũᵢ]`; the root tensor + is `[C̄_L(t₁); C̃_L]` — the amplitude and all first-order content enter exactly once, at the + root. A final `SvdCut` sweep truncates; `notrunc()` restores the pre-step virtual space of + every bond (per-bond `truncspace`, fixed-rank parallel BUG). +* `test/algorithms/parallelbug.jl` (40 tests, all passing): 2-site dense exactness, energy + + eigenstate-phase conservation, TDVP agreement, imaginary-time monotone lowering + norm, + bond growth under tight/loose `trscheme`, LazySum, **convergence order ≥ 1** (measured slope + ≈ 1.95–1.98 at these bond dimensions), **accuracy improves with ϑ** (vs a ϑ→0 run of the same + integrator, isolating the `c·n·ϑ` term: 5e-4 → 2.5e-6 → 1e-15), U(1)/Z2 charge + graded-structure + preservation. -## What works (asserted in the tests) +## What closed the first-order gap (was the open problem) -* **2-site: exact.** Reproduces the dense `exp(-iH·dt)` step to ~1e-12 (the matrix parallel-BUG - formulas are exact here — this locks the block conventions / adjoints). -* **Energy + eigenstate phase: exact.** `angle⟨ψ₁|ψ₀⟩/(dt·E₀) = 1` for `L = 2,3,4` (amplitude is - carried once, at the root), `|E₁−E₀| ~ 1e-15`. -* **Rank adaptivity: bonds grow.** A low-bond-dim start under a tight `truncerror` grows the bond - (e.g. 2 → 8) via the augment-then-`SvdCut` mechanism. -* Imaginary-time renormalization and symmetric-tensor (U(1)) total-charge preservation — see the - test file for the exact assertions that hold. +Two changes relative to the first WIP driver: -## The one open problem (marked `@test_broken`) +1. **The coupling blocks participate in the orthonormalization** (the `M = Û'U₀` reconciliation of + Alg. 4): the earlier driver placed the coupling in the `(new-row, old-col)` block of each + interior site tensor, which routes it through the amplitude-carrying root block and multiplies + it by the old bond matrix (σ-suppressed) — the interior first-order terms effectively vanished + (measured slope ≈ 0). In the correct assembly the interior tensors are *pure isometries* + `[old │ Ũᵢ]` and the coupling data enters `Ũᵢ`'s span via the stacked `Ĉ¹ᵢ = [C̄¹ᵢ; C̃ᵢ]`, so + deep new directions propagate to the root, where the single coupling row + `C̃_L = dt′·⟨Ũ¹_{L-1}|H|ψ₀⟩` captures every first-order component in one exact projection. + (Rank counting is fine: the needed new subspace at bond `b` is the range of the *summed* + tangent components, ≤ r_b-dimensional, not one r-dim family per site.) +2. **Amplitude-weighted kets**: the interior Galerkin solves and couplings act on `AC⁰[i]` + (`= AL⁰[i]·C⁰ᵢ`), not the bare isometry `AL⁰[i]`. With bare-`AL` kets the spans miss the needed + directions (H_eff and the bond matrix do not commute through the parent leg; measured slope + stayed ≈ 0). The amplitude these objects carry is discarded with the R-factor in the + orthonormalization, so no phase/energy overcounting occurs — this resolves the earlier + "full-environment effective Hamiltonian trilemma" (`assembly A/B/C`): the phase only ever + enters the state through the root blocks. -For `L > 2` the integrator does **not** yet attain the documented **first-order** accuracy: the -error does not decrease cleanly `∝ dt`, and tightening `ϑ` does not monotonically improve the -overlap with the dense reference. Root cause and analysis: +## Remaining work (why still experimental) -MPSKit's `AC_hamiltonian`/`C_hamiltonian` are **full-environment** effective Hamiltonians (each -carries the full energy `E₀`). This forces a trilemma when mapping the caterpillar (verified -numerically across three assemblies): - -| assembly | interior "old" block | phase ratio | order | -|---|---|---|---| -| A: keep evolved connecting tensor as a block | evolved `C̄¹_i` (amplitude kept) | ≈ `L` (overcounts) | — | -| B: freeze the basis, couple new dirs | frozen `AL[i]` (phase stripped) | **1** ✓ | ~0 (shipped) | -| C: symmetric `AC`+`C` regauge | evolved `AC`+`C` daggered | **1** ✓, exact @ L=2 | 2 (parallel-TDVP, not BUG) | - -The shipped driver is **assembly B**: it strips the interior phase correctly (ratio 1) and grows -bonds, but the first-order dynamics are not fully captured because the coupling-block -**reconciliation** is incomplete. Specifically, the paper's Alg. 4 builds the augmented basis from -the range of *both* the reprojected old connecting tensor `Ĉ⁰ = C⁰ ×_child Û'U⁰` **and** the -Galerkin-evolved `Ĉ¹`, and threads the overlap `M = Û'U₀` up toward the root. The shipped assembly -places the coupling directly in the new-child rows without that `M`-reconciliation, so the retained -subspace after `SvdCut` is not rotated by the correct `O(dt)` amount — hence the loss of clean first -order. (The `AC2_hamiltonian`-based coupling used for `C̃_i` is also a first suspect; the correct -one-site term is `dt·(AC_hamiltonian(i)·AC[i])` projected onto the new directions.) - -## Recommended path to close it - -1. Implement the leaves→root **`M = Û'U₀` reconciliation** (design doc §2b / paper Alg. 4A.2–A.4): - at each interior node reproject the *old* connecting tensor onto the already-augmented child - basis and build the parent-bond basis from the range of `[Mat₀(Ĉ⁰); Mat₀(Ĉ¹)]`, threading the - overlap upward — rather than the direct block placement used now. -2. Replace the two-site `AC2_hamiltonian` coupling with the one-site `dt·(AC_hamiltonian(i)·AC[i])` - projected onto `Ũ₁` (matrix eq. 3.1a analog). -3. Re-run `research/`'s gate harness (2-site exact / phase ratio / **slope ≈ 1** / **accuracy - improves with ϑ** / bond growth / symmetric preservation). The scaffolding and tests are ready; - flip the `@test_broken`s to `@test` once slope ≈ 1 holds. -4. Only then add step rejection (`η = ‖Ũ₁*F₀Ṽ₁‖`, `hη>cϑ`) and the `@sync`/`tmap!` threading (the - K-steps already read from one frozen snapshot, so threading is a localized change). - -Nothing here requires new MPSKit primitives — the earlier "need partial-energy node operators" -hypothesis is not necessary: the tree algorithm handles the full-energy operators by -*orthonormalizing* the interior evolved tensors (stripping their phase), which assembly B already -does; the remaining work is the coupling reconciliation, not the effective operators. +1. **Threading**: phase 1's `L` local exponential solves all read from the frozen snapshot and are + mutually independent — swap the `map` onto `@sync`/`Threads.@spawn` gated on + `Defaults.scheduler[]` (mirror `tdvp.jl`'s `InfiniteMPS` branch). Phase 2 is a cheap sequential + sweep (QRs + one derivative apply per site), as in the paper. +2. **Step rejection** (paper §3.3): `η = ‖Ũ₁'F₀Ṽ₁‖`, reject & recompute on the augmented bases if + `h·η > c·ϑ` or the truncation saturates the doubling cap; needs fields `c` (default 10) and + `maxiter_rejection`. +3. Minor: `timestep!`'s `envs` argument is currently only used for `L == 1`; the frozen snapshot + environments are recomputed each step (could be reused when the caller's `envs` are current). diff --git a/research/PARALLELBUG_design.md b/research/PARALLELBUG_design.md index b85243e20..52ea45fce 100644 --- a/research/PARALLELBUG_design.md +++ b/research/PARALLELBUG_design.md @@ -99,18 +99,27 @@ The per-bond matrix-DLRA mapping in §2 (an S-step via `C_hamiltonian(b)` on **e **The correct MPS mapping is the literal tree recursion, not L−1 independent matrix problems:** * The caterpillar rooted at `L` is **one** tree. Only the **root** connecting tensor (`AC[L]`) - carries amplitude/phase; every interior connecting tensor is an **isometry** (`AL[i]`). So there is - **no per-bond `C_hamiltonian` S-step at all** — the amplitude is evolved **once**, at the root. -* Interior nodes `i 2`. See - `research/PARALLELBUG_design.md` and `research/PARALLELBUG_STATUS.md`. The API and behaviour may - change. + This integrator is **work in progress**: the parallel-in-time structure is implemented (all + local solves read from one frozen snapshot), but the local solves are currently executed + serially, and the step-rejection criterion of Ceruti et al. (2024) is not yet implemented. + The API and behaviour may change. !!! note Real-time evolution does not normalize the resulting state: neither the augmentation nor the @@ -89,22 +97,34 @@ function timestep!( return ψ, envs end - # remember the pre-step bond dimensions for the fixed-rank (`notrunc`) restore - Ds = [dim(right_virtualspace(ψ, b)) for b in 1:(L - 1)] + # remember the pre-step virtual spaces for the fixed-rank (`notrunc`) restore + Vs = [right_virtualspace(ψ, b) for b in 1:(L - 1)] ϕ = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) - _pbug_truncate!(ϕ, alg, Ds; normalize = imaginary_evolution) + _pbug_truncate!(ϕ, alg, Vs; normalize = imaginary_evolution) # mutate `ψ` in place to become the assembled state (the generic loop reuses the object) _pbug_overwrite!(ψ, ϕ) return ψ, environments(ψ, H, ψ) end -# Assemble the augmented (pre-truncation) parallel-BUG state from a single frozen `t₀` snapshot. -# Root the caterpillar at site `L`; only the root center `AC[L]` carries amplitude, the interior -# connecting tensors are the isometries `AL[i]`. Each is evolved forward from the frozen snapshot; -# the interior evolutions define the new bond directions `Ũ`, and first-order coupling blocks connect -# them (the "new–new" corners are left at zero — this is what makes the integrator first order). +# Assemble the augmented (pre-truncation) parallel-BUG state from a single frozen `t₀` snapshot, +# following Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4 specialized to the caterpillar tree +# rooted at site `L`. Two phases: +# +# 1. Galerkin-evolve every local center `AC[i]` from the frozen snapshot (the K-steps of +# Alg. 2/3, on the amplitude-weighted subtree objects `Y_τ⁰ = U_τ⁰S_τ⁰`). These `L` local +# solves are mutually independent, which is the parallel-in-time structure of the integrator. +# 2. A leaves→root augmentation pass (Alg. 4): at bond `i` the new directions `Ũᵢ` are +# orthonormalized against the (zero-padded) old isometry from the evolved center *stacked with +# the first-order coupling block* `C̃ᵢ = dt′·⟨Ũᵢ₋₁|H|ψ₀⟩-derivative of AC[i]` on the new rows of +# the previous bond — this reconciliation is what lets directions discovered deep in the chain +# propagate to the root. Only the range of these stacks is kept (their amplitude and phase are +# discarded with the R-factor), so the interior site tensors of the augmented state are the +# isometries `[old │ Ũᵢ]`, and all first-order (and amplitude) content is carried by the root +# tensor `[C̄_L(t₁); C̃_L]`, whose coupling row projects the full derivative of `ψ₀` onto the +# new directions. The "new-new" corners are implicitly zero, which is what makes the integrator +# first order. function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) L = length(ψ) ψ.AC[L] # gauge to the root @@ -112,46 +132,82 @@ function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Boo envs₀ = environments(ψ₀, H, ψ₀) dt′ = imaginary_evolution ? -dt : -im * dt - # interior isometry evolutions → new bond directions `Ũ_i` on bond `i` - Us = Vector{Any}(undef, L - 1) - Ns = Vector{Any}(undef, L - 1) - for i in 1:(L - 1) - Cevo = integrate( - AC_hamiltonian(i, ψ₀, H, ψ₀, envs₀), ψ₀.AL[i], t, dt, alg.integrator; imaginary_evolution + # phase 1: frozen-snapshot Galerkin evolutions (independent local solves). The interior + # K-steps evolve the amplitude-weighted centers `AC[i]` (the paper's `Y_τ⁰ = U_τ⁰S_τ⁰`); only + # their range is kept, so the amplitude they carry is discarded in the orthonormalization. + Cevo = map(1:(L - 1)) do i + return integrate( + AC_hamiltonian(i, ψ₀, H, ψ₀, envs₀), ψ₀.AC[i], t, dt, alg.integrator; + imaginary_evolution ) - Us[i], Ns[i] = _pbug_newdirs(ψ₀.AL[i], Cevo, alg.alg_orth) end - # root center evolution (carries the amplitude / eigenstate phase, once) - CbarL = integrate( + C̄L = integrate( AC_hamiltonian(L, ψ₀, H, ψ₀, envs₀), ψ₀.AC[L], t, dt, alg.integrator; imaginary_evolution ) - # first-order coupling blocks C̃_i : Ñ_{i-1} ⊗ P_i ← V_i, from the frozen two-site derivative - Ctil = Vector{Any}(undef, L) - for i in 2:L - T2 = _transpose_front(ψ₀.AL[i - 1]) * _transpose_tail(ψ₀.AC[i]) - F2 = AC2_hamiltonian(i - 1, ψ₀, H, ψ₀, envs₀) * T2 - Ctil[i] = scale(_transpose_front(Us[i - 1]' * F2), dt′) - end - - # stack the augmented site tensors (old block first, coupling in the new-child rows, zero corner) + # phase 2: leaves→root augmentation sweep, threading the mixed ⟨augmented|H|old⟩ environments As = Vector{Any}(undef, L) - As[1] = catdomain(copy(ψ₀.AL[1]), Us[1]) - for i in 2:(L - 1) - top = catdomain(copy(ψ₀.AL[i]), Us[i]) - Z = zerovector!(similar(Ctil[i], codomain(Ctil[i]) ← Ns[i])) - bot = catdomain(Ctil[i], Z) - As[i] = _pbug_stack_child(top, bot) + GLmix = _pbug_mixedenv_init(H, envs₀, ψ₀) # full augmented rows (trivial at the left edge) + local GLnew # new-direction rows only + for i in 1:(L - 1) + if i == 1 + C⁰, Ĉ¹ = ψ₀.AL[1], Cevo[1] + else + # first-order coupling block on the new rows of bond i-1 + C̃ = scale(_pbug_coupling_hamiltonian(GLnew, H, i, envs₀, ψ₀)(ψ₀.AC[i]), dt′) + C⁰ = _pbug_stack_child(ψ₀.AL[i], zerovector!(similar(C̃))) + Ĉ¹ = _pbug_stack_child(Cevo[i], C̃) + end + Ũ, = _pbug_newdirs(C⁰, Ĉ¹, alg.alg_orth) + As[i] = catdomain(C⁰, Ũ) + GLnew = _pbug_mixedenv_step(GLmix, H, i, ψ₀.AL[i], Ũ) + i == L - 1 && break # the full mixed environment is no longer needed + GLmix = _pbug_mixedenv_step(GLmix, H, i, ψ₀.AL[i], As[i]) end - As[L] = _pbug_stack_child(CbarL, Ctil[L]) + # root: the amplitude is carried once, by the evolved center and its coupling row + C̃L = scale(_pbug_coupling_hamiltonian(GLnew, H, L, envs₀, ψ₀)(ψ₀.AC[L]), dt′) + As[L] = _pbug_stack_child(C̄L, C̃L) return FiniteMPS( convert(Vector{typeof(As[1])}, As); overwrite = true, normalize = imaginary_evolution ) end -# new bond directions: the component of the evolved (interior) isometry orthogonal to the old basis, -# re-orthonormalized. Returns the isometry `Ũ` and the new bond space. +# Mixed ⟨augmented|H|old⟩ left environments: initial (trivial-bond) environment and one-site +# transfer step, with the same operator dispatch as the effective-Hamiltonian constructors. +_pbug_mixedenv_init(H, envs, ψ₀) = leftenv(envs, 1, ψ₀) +function _pbug_mixedenv_init(H::MultipliedOperator, envs, ψ₀) + return _pbug_mixedenv_init(H.op, envs, ψ₀) +end +function _pbug_mixedenv_init(H::LazySum, envs::MultipleEnvironments, ψ₀) + return map((o, e) -> _pbug_mixedenv_init(o, e, ψ₀), H.ops, envs.envs) +end + +_pbug_mixedenv_step(GL, H, i, above, below) = GL * TransferMatrix(above, H[i], below) +function _pbug_mixedenv_step(GL, H::MultipliedOperator, i, above, below) + return _pbug_mixedenv_step(GL, H.op, i, above, below) +end +function _pbug_mixedenv_step(GLs::Vector, H::LazySum, i, above, below) + return map((gl, o) -> _pbug_mixedenv_step(gl, o, i, above, below), GLs, H.ops) +end + +# One-site effective derivative with the mixed new-direction rows as left environment and the +# frozen old right environment: applied to the center `AC[i]` this yields the first-order coupling +# block `C̃ᵢ` (Alg. 4's `h·F(Y_τ⁰) ×ⱼ U⁰* ×ᵢ Ũ¹*`). +function _pbug_coupling_hamiltonian(GL, H, i, envs, ψ₀) + return MPO_AC_Hamiltonian(GL, H[i], rightenv(envs, i, ψ₀)) +end +function _pbug_coupling_hamiltonian(GL, H::MultipliedOperator, i, envs, ψ₀) + return MultipliedOperator(_pbug_coupling_hamiltonian(GL, H.op, i, envs, ψ₀), H.f) +end +function _pbug_coupling_hamiltonian(GLs::Vector, H::LazySum, i, envs::MultipleEnvironments, ψ₀) + Hs = map((gl, o, e) -> _pbug_coupling_hamiltonian(gl, o, i, e, ψ₀), GLs, H.ops, envs.envs) + elT = Union{D, MultipliedOperator{D}} where {D <: DerivativeOperator} + return LazySum{elT}(Hs) +end + +# new bond directions: the component of the evolved (stacked) candidate orthogonal to the old +# basis, re-orthonormalized. Returns the isometry `Ũ` and the new bond space. function _pbug_newdirs(AL, Cevo, alg_orth = Defaults.alg_orth()) N = left_null(AL) g = N' * Cevo @@ -167,11 +223,20 @@ _pbug_stack_child(top, bot) = _pbug_truncates(alg::ParallelBUG) = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) # Cut the augmented bonds back down. A truncating `trscheme` selects rank-adaptivity; the default -# `notrunc()` restores the pre-step bond dimensions (fixed-rank parallel BUG). Environments -# self-heal lazily for the changed bonds. -function _pbug_truncate!(ϕ, alg::ParallelBUG, Ds; normalize::Bool = false) - trscheme = _pbug_truncates(alg) ? alg.trscheme : truncrank(maximum(Ds)) - changebonds!(ϕ, SvdCut(; trscheme, alg_svd = alg.alg_svd); normalize) +# `notrunc()` restores the pre-step virtual space of every bond (fixed-rank parallel BUG). +# Environments self-heal lazily for the changed bonds. +function _pbug_truncate!(ϕ, alg::ParallelBUG, Vs; normalize::Bool = false) + if _pbug_truncates(alg) + changebonds!(ϕ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) + else + # per-bond fixed-rank restore (mirrors the `SvdCut` sweep with a per-bond `trscheme`) + for i in (length(ϕ) - 1):-1:1 + U, S, Vᴴ = svd_trunc(ϕ.C[i]; trunc = truncspace(Vs[i]), alg = alg.alg_svd) + ϕ.AC[i] = (ϕ.AL[i] * U, S) + ϕ.AC[i + 1] = (S, _transpose_front(Vᴴ * _transpose_tail(ϕ.AR[i + 1]))) + end + normalize && normalize!(ϕ) + end return ϕ end diff --git a/test/algorithms/parallelbug.jl b/test/algorithms/parallelbug.jl index 42f521ff5..aa1843f28 100644 --- a/test/algorithms/parallelbug.jl +++ b/test/algorithms/parallelbug.jl @@ -12,13 +12,11 @@ using TensorKit: ℙ using LinearAlgebra: dot, norm using Random -# NOTE (experimental / WIP). `ParallelBUG` is the parallel Basis-Update & Galerkin integrator +# NOTE (experimental). `ParallelBUG` is the parallel Basis-Update & Galerkin integrator # (Ceruti et al. 2024) specialized to the caterpillar `FiniteMPS`. It reproduces the exact matrix # parallel-BUG step for two sites, conserves energy / the eigenstate phase exactly (amplitude carried -# once, at the root), grows bonds adaptively, and agrees with `TDVP` over short times. Its documented -# *first-order* accuracy for `L > 2` is NOT yet attained — the coupling-block reconciliation -# (`M = Û'U₀`, Ceruti et al. Alg. 4) is incomplete — so the two accuracy/order gates below are -# `@test_broken` and flip to `@test` once that lands. See `research/PARALLELBUG_STATUS.md`. +# once, at the root), grows bonds adaptively, agrees with `TDVP` over short times, and converges at +# (at least) the documented first order in `dt` toward the dense reference. @testset "ParallelBUG time evolution" verbose = true begin dt = 0.1 L = 6 @@ -117,11 +115,10 @@ using Random @test (3 + 1.55 - 0.1) * E₀ ≈ E atol = 1.0e-2 end - # 7. KNOWN LIMITATION (WIP): the integrator is not yet first order for L > 2. The measured - # log–log slope is ≈ 0 (zeroth order) rather than the target ≈ 1, and the overlap error does - # not improve as the truncation tolerance ϑ shrinks. These are `@test_broken` until the - # coupling-block reconciliation (`research/PARALLELBUG_STATUS.md`) is completed. - @testset "first-order accuracy (KNOWN BROKEN — WIP)" begin + # 7. convergence order: the integrator is documented as (globally) first order in dt; assert at + # least that. (At these bond dimensions the augmented spans are near-exact and the measured + # slope is ≈ 2, so only a lower bound is imposed.) + @testset "first-order accuracy" begin Random.seed!(2) Lc = 4 Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) @@ -145,31 +142,32 @@ using Random (log(errs[i + 1]) - log(errs[i])) / (log(dts[i + 1]) - log(dts[i])) for i in 1:(length(dts) - 1) ] - @info "ParallelBUG convergence (WIP)" errs slopes - # target: first order (slope ≈ 1). Currently measures ≈ 0. - @test_broken all(s -> isapprox(s, 1; atol = 0.3), slopes) + @info "ParallelBUG convergence" errs slopes + # documented rate: first order (slope ≈ 1); assert at least that + @test all(s -> s > 0.8, slopes) end - @testset "accuracy improves as ϑ decreases (KNOWN BROKEN — WIP)" begin + # 8. the truncation tolerance ϑ maps onto the accumulating `c·n·ϑ` error term: tightening ϑ + # improves the overlap with a ϑ → 0 run of the same integrator (which shares the time + # discretization error, so the comparison isolates the truncation term). + @testset "accuracy improves as ϑ decreases" begin Random.seed!(202) Lc = 6 Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) ψ0 = normalize!(complex(FiniteMPS(rand, Float64, Lc, ℙ^2, ℙ^2))) - Hmat = convert(TensorMap, Hc) - ψvec = convert(TensorMap, ψ0); ψvec /= norm(ψvec) Tfin = 0.2; δt = 0.05; n = round(Int, Tfin / δt) - ref = exp(-im * Hmat * (n * δt)) * ψvec + evolve(ϑ) = foldl(0:(n - 1); init = ψ0) do ψc, k + first(timestep(ψc, Hc, k * δt, δt, ParallelBUG(; trscheme = truncerror(; atol = ϑ)))) + end + ref = evolve(1.0e-12) ϑs = [1.0e-2, 1.0e-4, 1.0e-6] errs = map(ϑs) do ϑ - ψc = copy(ψ0) - for k in 0:(n - 1) - ψc, = timestep(ψc, Hc, k * δt, δt, ParallelBUG(; trscheme = truncerror(; atol = ϑ))) - end - out = convert(TensorMap, ψc); out /= norm(out) - return 1 - abs(dot(out, ref)) + ψc = evolve(ϑ) + return 1 - abs(dot(ψc, ref)) / (norm(ψc) * norm(ref)) end - @info "ParallelBUG accuracy vs ϑ (WIP)" ϑs errs - @test_broken errs[end] < errs[1] / 2 + @info "ParallelBUG accuracy vs ϑ" ϑs errs + @test issorted(errs; rev = true) + @test errs[end] < errs[1] / 10 end end From 9c2514f4f5b62aaea8ed190f068793622ab7e4df Mon Sep 17 00:00:00 2001 From: lkdvos Date: Thu, 9 Jul 2026 14:54:57 -0400 Subject: [PATCH 14/23] BUG: merge ParallelBUG into bug.jl Both Basis-Update & Galerkin integrators now live in src/algorithms/timestep/bug.jl (sequential BUG first, then a Parallel BUG section). The copying timestep wrapper, previously duplicated verbatim, is shared via alg::Union{BUG, ParallelBUG}, and the single-use _pbug_truncates predicate is inlined into _pbug_truncate!. No behavioural changes: both the BUG and ParallelBUG test suites pass unchanged. Test files stay separate so ParallelTestRunner keeps running them in parallel. Co-Authored-By: Claude Fable 5 --- research/PARALLELBUG_STATUS.md | 2 +- src/MPSKit.jl | 1 - src/algorithms/timestep/bug.jl | 255 ++++++++++++++++++++++- src/algorithms/timestep/parallelbug.jl | 269 ------------------------- 4 files changed, 254 insertions(+), 273 deletions(-) delete mode 100644 src/algorithms/timestep/parallelbug.jl diff --git a/research/PARALLELBUG_STATUS.md b/research/PARALLELBUG_STATUS.md index 074a31890..ba93f3593 100644 --- a/research/PARALLELBUG_STATUS.md +++ b/research/PARALLELBUG_STATUS.md @@ -7,7 +7,7 @@ local solves are still executed serially and step rejection is not implemented. ## What is implemented (branch `ld-parallelbug`) * `ParallelBUG <: Algorithm` struct + kw-constructor + docstring, registered and exported - (`src/algorithms/timestep/parallelbug.jl`). + (`src/algorithms/timestep/bug.jl`, which holds both BUG integrators). * A `timestep!` + copying `timestep` implementing Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1–4 specialized to the caterpillar tree rooted at site `L`, in two phases: 1. **Frozen-snapshot Galerkin evolutions** (Alg. 2/3): one frozen `ψ₀ = copy(ψ)` + `envs₀`; the diff --git a/src/MPSKit.jl b/src/MPSKit.jl index 687928791..42026916c 100644 --- a/src/MPSKit.jl +++ b/src/MPSKit.jl @@ -162,7 +162,6 @@ include("algorithms/changebonds/sketchedexpand.jl") include("algorithms/timestep/tdvp.jl") include("algorithms/timestep/bug.jl") -include("algorithms/timestep/parallelbug.jl") include("algorithms/timestep/taylorcluster.jl") include("algorithms/timestep/wii.jl") include("algorithms/timestep/integrators.jl") diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 2d04c7765..9d693628f 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -146,10 +146,261 @@ function _bug_augment_right(U₀, K₁, alg_orth = Defaults.alg_orth()) return _transpose_front(Û) end -# copying version +# Parallel BUG +# ------------ + +""" +$(TYPEDEF) + +Single site MPS time-evolution algorithm based on the *parallel* Basis-Update & Galerkin (BUG) +integrator for tree tensor networks, specialized to the linear (`FiniteMPS`) tree. + +Unlike the sequential [`BUG`](@ref), every local problem is solved from the **same frozen `t₀` +snapshot** (`ψ₀ = copy(ψ)` and its environments): there is no sweep and no sequential dependency +between the local integrations, so they are mutually independent and parallelizable. Rooting the +caterpillar at the last site, every local center `AC[i]` is evolved forward from `t₀`. A cheap +leaves→root assembly pass (the augmentation `A_τ` of Ceruti et al. 2024, Alg. 4) then augments +every bond with the new directions discovered by the frozen evolutions — old basis first, +`[U₀ │ Ũ₁]` — where the new directions at each bond are orthonormalized from the evolved center +*stacked with a first-order coupling block* on the new rows of the previous bond, so that +directions discovered deep in the chain propagate up toward the root. The interior tensors of the +augmented state are isometries (the amplitude and phase of the evolved centers is discarded with +the orthonormalization); the root tensor is the evolved center `AC[L]` augmented with the +projection of the full time derivative onto the new directions, so the amplitude and all +first-order updates enter exactly once, at the root. Finally an SVD sweep truncates the (at most +doubled) bonds back down. +Like [`BUG`](@ref), it advances every tensor *forward* in time (no backward substep), which suits +imaginary-time / dissipative evolution. + +The integrator is **first-order** in time (the discarded "new–new" coupling corners are `O(dt²)`), +in contrast to the second-order symmetrized sequential [`BUG`](@ref). The truncation tolerance of +`trscheme` maps to the BUG tolerance `ϑ`; because the global error accumulates as `c·n·ϑ` over `n` +steps, scale `ϑ` with `dt` for a fixed target accuracy. Any truncating `trscheme` (e.g. +`truncerror`) makes the bond dimension grow and shrink to track the entanglement of the evolving +state; the default `notrunc()` restores the pre-step virtual spaces after every step (fixed-rank +parallel BUG). + +!!! warning "Experimental" + This integrator is **work in progress**: the parallel-in-time structure is implemented (all + local solves read from one frozen snapshot), but the local solves are currently executed + serially, and the step-rejection criterion of Ceruti et al. (2024) is not yet implemented. + The API and behaviour may change. + +!!! note + Real-time evolution does not normalize the resulting state: neither the augmentation nor the + truncation normalizes, so the state norm reflects the accumulated truncation error. + Imaginary-time evolution renormalizes after every step, similar to a ground-state search. + +## Fields + +$(TYPEDFIELDS) + +## References + +* Ceruti, Kusch & Lubich, *A parallel rank-adaptive integrator for dynamical low-rank + approximation*, SIAM J. Sci. Comput. **46** (2024). +* Ceruti, Kusch, Lubich & Sulz, *A parallel Basis Update and Galerkin integrator for tree tensor + networks*, arXiv:2412.00858 (2024). +""" +struct ParallelBUG{A, O, T, S, F} <: Algorithm + "algorithm used in the exponential solvers" + integrator::A + + "tolerance for gauging algorithm" + tolgauge::Float64 + + "maximal amount of iterations for gauging algorithm" + gaugemaxiter::Int + + "algorithm used to re-orthonormalize the basis after each local update" + alg_orth::O + + "truncation scheme used to cut the augmented bonds back down" + trscheme::T + + "algorithm used for the singular value decomposition" + alg_svd::S + + "callback function applied after each iteration, of signature `finalize(iter, ψ, H, envs) -> ψ, envs`" + finalize::F +end +function ParallelBUG(; + integrator = Defaults.alg_expsolve(), tolgauge = Defaults.tolgauge, + gaugemaxiter = Defaults.maxiter, alg_orth = Defaults.alg_orth(), + trscheme = notrunc(), alg_svd = Defaults.alg_svd(), + finalize = Defaults._finalize + ) + return ParallelBUG(integrator, tolgauge, gaugemaxiter, alg_orth, trscheme, alg_svd, finalize) +end + +function timestep!( + ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::ParallelBUG, + envs::AbstractMPSEnvironments = environments(ψ, H, ψ); + imaginary_evolution::Bool = false + ) + L = length(ψ) + if L == 1 # single site: a plain forward center step + AC = integrate( + AC_hamiltonian(1, ψ, H, ψ, envs), ψ.AC[1], t, dt, alg.integrator; imaginary_evolution + ) + imaginary_evolution && normalize!(AC) + ψ.AC[1] = AC + return ψ, envs + end + + # remember the pre-step virtual spaces for the fixed-rank (`notrunc`) restore + Vs = [right_virtualspace(ψ, b) for b in 1:(L - 1)] + + ϕ = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) + _pbug_truncate!(ϕ, alg, Vs; normalize = imaginary_evolution) + + # mutate `ψ` in place to become the assembled state (the generic loop reuses the object) + _pbug_overwrite!(ψ, ϕ) + return ψ, environments(ψ, H, ψ) +end + +# Assemble the augmented (pre-truncation) parallel-BUG state from a single frozen `t₀` snapshot, +# following Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4 specialized to the caterpillar tree +# rooted at site `L`. Two phases: +# +# 1. Galerkin-evolve every local center `AC[i]` from the frozen snapshot (the K-steps of +# Alg. 2/3, on the amplitude-weighted subtree objects `Y_τ⁰ = U_τ⁰S_τ⁰`). These `L` local +# solves are mutually independent, which is the parallel-in-time structure of the integrator. +# 2. A leaves→root augmentation pass (Alg. 4): at bond `i` the new directions `Ũᵢ` are +# orthonormalized against the (zero-padded) old isometry from the evolved center *stacked with +# the first-order coupling block* `C̃ᵢ = dt′·⟨Ũᵢ₋₁|H|ψ₀⟩-derivative of AC[i]` on the new rows of +# the previous bond — this reconciliation is what lets directions discovered deep in the chain +# propagate to the root. Only the range of these stacks is kept (their amplitude and phase are +# discarded with the R-factor), so the interior site tensors of the augmented state are the +# isometries `[old │ Ũᵢ]`, and all first-order (and amplitude) content is carried by the root +# tensor `[C̄_L(t₁); C̃_L]`, whose coupling row projects the full derivative of `ψ₀` onto the +# new directions. The "new-new" corners are implicitly zero, which is what makes the integrator +# first order. +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) + L = length(ψ) + ψ.AC[L] # gauge to the root + ψ₀ = copy(ψ) + envs₀ = environments(ψ₀, H, ψ₀) + dt′ = imaginary_evolution ? -dt : -im * dt + + # phase 1: frozen-snapshot Galerkin evolutions (independent local solves). The interior + # K-steps evolve the amplitude-weighted centers `AC[i]` (the paper's `Y_τ⁰ = U_τ⁰S_τ⁰`); only + # their range is kept, so the amplitude they carry is discarded in the orthonormalization. + Cevo = map(1:(L - 1)) do i + return integrate( + AC_hamiltonian(i, ψ₀, H, ψ₀, envs₀), ψ₀.AC[i], t, dt, alg.integrator; + imaginary_evolution + ) + end + C̄L = integrate( + AC_hamiltonian(L, ψ₀, H, ψ₀, envs₀), ψ₀.AC[L], t, dt, alg.integrator; imaginary_evolution + ) + + # phase 2: leaves→root augmentation sweep, threading the mixed ⟨augmented|H|old⟩ environments + As = Vector{Any}(undef, L) + GLmix = _pbug_mixedenv_init(H, envs₀, ψ₀) # full augmented rows (trivial at the left edge) + local GLnew # new-direction rows only + for i in 1:(L - 1) + if i == 1 + C⁰, Ĉ¹ = ψ₀.AL[1], Cevo[1] + else + # first-order coupling block on the new rows of bond i-1 + C̃ = scale(_pbug_coupling_hamiltonian(GLnew, H, i, envs₀, ψ₀)(ψ₀.AC[i]), dt′) + C⁰ = _pbug_stack_child(ψ₀.AL[i], zerovector!(similar(C̃))) + Ĉ¹ = _pbug_stack_child(Cevo[i], C̃) + end + Ũ, = _pbug_newdirs(C⁰, Ĉ¹, alg.alg_orth) + As[i] = catdomain(C⁰, Ũ) + GLnew = _pbug_mixedenv_step(GLmix, H, i, ψ₀.AL[i], Ũ) + i == L - 1 && break # the full mixed environment is no longer needed + GLmix = _pbug_mixedenv_step(GLmix, H, i, ψ₀.AL[i], As[i]) + end + # root: the amplitude is carried once, by the evolved center and its coupling row + C̃L = scale(_pbug_coupling_hamiltonian(GLnew, H, L, envs₀, ψ₀)(ψ₀.AC[L]), dt′) + As[L] = _pbug_stack_child(C̄L, C̃L) + + return FiniteMPS( + convert(Vector{typeof(As[1])}, As); overwrite = true, normalize = imaginary_evolution + ) +end + +# Mixed ⟨augmented|H|old⟩ left environments: initial (trivial-bond) environment and one-site +# transfer step, with the same operator dispatch as the effective-Hamiltonian constructors. +_pbug_mixedenv_init(H, envs, ψ₀) = leftenv(envs, 1, ψ₀) +function _pbug_mixedenv_init(H::MultipliedOperator, envs, ψ₀) + return _pbug_mixedenv_init(H.op, envs, ψ₀) +end +function _pbug_mixedenv_init(H::LazySum, envs::MultipleEnvironments, ψ₀) + return map((o, e) -> _pbug_mixedenv_init(o, e, ψ₀), H.ops, envs.envs) +end + +_pbug_mixedenv_step(GL, H, i, above, below) = GL * TransferMatrix(above, H[i], below) +function _pbug_mixedenv_step(GL, H::MultipliedOperator, i, above, below) + return _pbug_mixedenv_step(GL, H.op, i, above, below) +end +function _pbug_mixedenv_step(GLs::Vector, H::LazySum, i, above, below) + return map((gl, o) -> _pbug_mixedenv_step(gl, o, i, above, below), GLs, H.ops) +end + +# One-site effective derivative with the mixed new-direction rows as left environment and the +# frozen old right environment: applied to the center `AC[i]` this yields the first-order coupling +# block `C̃ᵢ` (Alg. 4's `h·F(Y_τ⁰) ×ⱼ U⁰* ×ᵢ Ũ¹*`). +function _pbug_coupling_hamiltonian(GL, H, i, envs, ψ₀) + return MPO_AC_Hamiltonian(GL, H[i], rightenv(envs, i, ψ₀)) +end +function _pbug_coupling_hamiltonian(GL, H::MultipliedOperator, i, envs, ψ₀) + return MultipliedOperator(_pbug_coupling_hamiltonian(GL, H.op, i, envs, ψ₀), H.f) +end +function _pbug_coupling_hamiltonian(GLs::Vector, H::LazySum, i, envs::MultipleEnvironments, ψ₀) + Hs = map((gl, o, e) -> _pbug_coupling_hamiltonian(gl, o, i, e, ψ₀), GLs, H.ops, envs.envs) + elT = Union{D, MultipliedOperator{D}} where {D <: DerivativeOperator} + return LazySum{elT}(Hs) +end + +# new bond directions: the component of the evolved (stacked) candidate orthogonal to the old +# basis, re-orthonormalized. Returns the isometry `Ũ` and the new bond space. +function _pbug_newdirs(AL, Cevo, alg_orth = Defaults.alg_orth()) + N = left_null(AL) + g = N' * Cevo + Q, _ = left_orth(g; alg = alg_orth) + Ũ = N * Q + return Ũ, domain(Q) +end + +# stack two MPS tensors along the child (left-virtual) bond: `[top; bot]`, doubling that bond +_pbug_stack_child(top, bot) = + _transpose_front(catcodomain(_transpose_tail(top), _transpose_tail(bot))) + +# Cut the augmented bonds back down. A truncating `trscheme` selects rank-adaptivity; the default +# `notrunc()` restores the pre-step virtual space of every bond (fixed-rank parallel BUG). +# Environments self-heal lazily for the changed bonds. +function _pbug_truncate!(ϕ, alg::ParallelBUG, Vs; normalize::Bool = false) + if !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) + changebonds!(ϕ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) + else + # per-bond fixed-rank restore (mirrors the `SvdCut` sweep with a per-bond `trscheme`) + for i in (length(ϕ) - 1):-1:1 + U, S, Vᴴ = svd_trunc(ϕ.C[i]; trunc = truncspace(Vs[i]), alg = alg.alg_svd) + ϕ.AC[i] = (ϕ.AL[i] * U, S) + ϕ.AC[i + 1] = (S, _transpose_front(Vᴴ * _transpose_tail(ϕ.AR[i + 1]))) + end + normalize && normalize!(ϕ) + end + return ϕ +end + +# overwrite the internal representation of `ψ` with that of `ϕ` (same length, possibly new bonds) +function _pbug_overwrite!(ψ::FiniteMPS, ϕ::FiniteMPS) + for f in (:ALs, :ARs, :ACs, :Cs) + copyto!(getfield(ψ, f), getfield(ϕ, f)) + end + return ψ +end + +# copying version, shared by both BUG integrators function timestep( ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, - alg::BUG, envs::AbstractMPSEnvironments...; + alg::Union{BUG, ParallelBUG}, envs::AbstractMPSEnvironments...; imaginary_evolution::Bool = false, kwargs... ) isreal = (scalartype(ψ) <: Real && !imaginary_evolution) diff --git a/src/algorithms/timestep/parallelbug.jl b/src/algorithms/timestep/parallelbug.jl deleted file mode 100644 index 362d0a844..000000000 --- a/src/algorithms/timestep/parallelbug.jl +++ /dev/null @@ -1,269 +0,0 @@ -""" -$(TYPEDEF) - -Single site MPS time-evolution algorithm based on the *parallel* Basis-Update & Galerkin (BUG) -integrator for tree tensor networks, specialized to the linear (`FiniteMPS`) tree. - -Unlike the sequential [`BUG`](@ref), every local problem is solved from the **same frozen `t₀` -snapshot** (`ψ₀ = copy(ψ)` and its environments): there is no sweep and no sequential dependency -between the local integrations, so they are mutually independent and parallelizable. Rooting the -caterpillar at the last site, every local center `AC[i]` is evolved forward from `t₀`. A cheap -leaves→root assembly pass (the augmentation `A_τ` of Ceruti et al. 2024, Alg. 4) then augments -every bond with the new directions discovered by the frozen evolutions — old basis first, -`[U₀ │ Ũ₁]` — where the new directions at each bond are orthonormalized from the evolved center -*stacked with a first-order coupling block* on the new rows of the previous bond, so that -directions discovered deep in the chain propagate up toward the root. The interior tensors of the -augmented state are isometries (the amplitude and phase of the evolved centers is discarded with -the orthonormalization); the root tensor is the evolved center `AC[L]` augmented with the -projection of the full time derivative onto the new directions, so the amplitude and all -first-order updates enter exactly once, at the root. Finally an SVD sweep truncates the (at most -doubled) bonds back down. -Like [`BUG`](@ref), it advances every tensor *forward* in time (no backward substep), which suits -imaginary-time / dissipative evolution. - -The integrator is **first-order** in time (the discarded "new–new" coupling corners are `O(dt²)`), -in contrast to the second-order symmetrized sequential [`BUG`](@ref). The truncation tolerance of -`trscheme` maps to the BUG tolerance `ϑ`; because the global error accumulates as `c·n·ϑ` over `n` -steps, scale `ϑ` with `dt` for a fixed target accuracy. Any truncating `trscheme` (e.g. -`truncerror`) makes the bond dimension grow and shrink to track the entanglement of the evolving -state; the default `notrunc()` restores the pre-step virtual spaces after every step (fixed-rank -parallel BUG). - -!!! warning "Experimental" - This integrator is **work in progress**: the parallel-in-time structure is implemented (all - local solves read from one frozen snapshot), but the local solves are currently executed - serially, and the step-rejection criterion of Ceruti et al. (2024) is not yet implemented. - The API and behaviour may change. - -!!! note - Real-time evolution does not normalize the resulting state: neither the augmentation nor the - truncation normalizes, so the state norm reflects the accumulated truncation error. - Imaginary-time evolution renormalizes after every step, similar to a ground-state search. - -## Fields - -$(TYPEDFIELDS) - -## References - -* Ceruti, Kusch & Lubich, *A parallel rank-adaptive integrator for dynamical low-rank - approximation*, SIAM J. Sci. Comput. **46** (2024). -* Ceruti, Kusch, Lubich & Sulz, *A parallel Basis Update and Galerkin integrator for tree tensor - networks*, arXiv:2412.00858 (2024). -""" -struct ParallelBUG{A, O, T, S, F} <: Algorithm - "algorithm used in the exponential solvers" - integrator::A - - "tolerance for gauging algorithm" - tolgauge::Float64 - - "maximal amount of iterations for gauging algorithm" - gaugemaxiter::Int - - "algorithm used to re-orthonormalize the basis after each local update" - alg_orth::O - - "truncation scheme used to cut the augmented bonds back down" - trscheme::T - - "algorithm used for the singular value decomposition" - alg_svd::S - - "callback function applied after each iteration, of signature `finalize(iter, ψ, H, envs) -> ψ, envs`" - finalize::F -end -function ParallelBUG(; - integrator = Defaults.alg_expsolve(), tolgauge = Defaults.tolgauge, - gaugemaxiter = Defaults.maxiter, alg_orth = Defaults.alg_orth(), - trscheme = notrunc(), alg_svd = Defaults.alg_svd(), - finalize = Defaults._finalize - ) - return ParallelBUG(integrator, tolgauge, gaugemaxiter, alg_orth, trscheme, alg_svd, finalize) -end - -function timestep!( - ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::ParallelBUG, - envs::AbstractMPSEnvironments = environments(ψ, H, ψ); - imaginary_evolution::Bool = false - ) - L = length(ψ) - if L == 1 # single site: a plain forward center step - AC = integrate( - AC_hamiltonian(1, ψ, H, ψ, envs), ψ.AC[1], t, dt, alg.integrator; imaginary_evolution - ) - imaginary_evolution && normalize!(AC) - ψ.AC[1] = AC - return ψ, envs - end - - # remember the pre-step virtual spaces for the fixed-rank (`notrunc`) restore - Vs = [right_virtualspace(ψ, b) for b in 1:(L - 1)] - - ϕ = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) - _pbug_truncate!(ϕ, alg, Vs; normalize = imaginary_evolution) - - # mutate `ψ` in place to become the assembled state (the generic loop reuses the object) - _pbug_overwrite!(ψ, ϕ) - return ψ, environments(ψ, H, ψ) -end - -# Assemble the augmented (pre-truncation) parallel-BUG state from a single frozen `t₀` snapshot, -# following Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4 specialized to the caterpillar tree -# rooted at site `L`. Two phases: -# -# 1. Galerkin-evolve every local center `AC[i]` from the frozen snapshot (the K-steps of -# Alg. 2/3, on the amplitude-weighted subtree objects `Y_τ⁰ = U_τ⁰S_τ⁰`). These `L` local -# solves are mutually independent, which is the parallel-in-time structure of the integrator. -# 2. A leaves→root augmentation pass (Alg. 4): at bond `i` the new directions `Ũᵢ` are -# orthonormalized against the (zero-padded) old isometry from the evolved center *stacked with -# the first-order coupling block* `C̃ᵢ = dt′·⟨Ũᵢ₋₁|H|ψ₀⟩-derivative of AC[i]` on the new rows of -# the previous bond — this reconciliation is what lets directions discovered deep in the chain -# propagate to the root. Only the range of these stacks is kept (their amplitude and phase are -# discarded with the R-factor), so the interior site tensors of the augmented state are the -# isometries `[old │ Ũᵢ]`, and all first-order (and amplitude) content is carried by the root -# tensor `[C̄_L(t₁); C̃_L]`, whose coupling row projects the full derivative of `ψ₀` onto the -# new directions. The "new-new" corners are implicitly zero, which is what makes the integrator -# first order. -function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) - L = length(ψ) - ψ.AC[L] # gauge to the root - ψ₀ = copy(ψ) - envs₀ = environments(ψ₀, H, ψ₀) - dt′ = imaginary_evolution ? -dt : -im * dt - - # phase 1: frozen-snapshot Galerkin evolutions (independent local solves). The interior - # K-steps evolve the amplitude-weighted centers `AC[i]` (the paper's `Y_τ⁰ = U_τ⁰S_τ⁰`); only - # their range is kept, so the amplitude they carry is discarded in the orthonormalization. - Cevo = map(1:(L - 1)) do i - return integrate( - AC_hamiltonian(i, ψ₀, H, ψ₀, envs₀), ψ₀.AC[i], t, dt, alg.integrator; - imaginary_evolution - ) - end - C̄L = integrate( - AC_hamiltonian(L, ψ₀, H, ψ₀, envs₀), ψ₀.AC[L], t, dt, alg.integrator; imaginary_evolution - ) - - # phase 2: leaves→root augmentation sweep, threading the mixed ⟨augmented|H|old⟩ environments - As = Vector{Any}(undef, L) - GLmix = _pbug_mixedenv_init(H, envs₀, ψ₀) # full augmented rows (trivial at the left edge) - local GLnew # new-direction rows only - for i in 1:(L - 1) - if i == 1 - C⁰, Ĉ¹ = ψ₀.AL[1], Cevo[1] - else - # first-order coupling block on the new rows of bond i-1 - C̃ = scale(_pbug_coupling_hamiltonian(GLnew, H, i, envs₀, ψ₀)(ψ₀.AC[i]), dt′) - C⁰ = _pbug_stack_child(ψ₀.AL[i], zerovector!(similar(C̃))) - Ĉ¹ = _pbug_stack_child(Cevo[i], C̃) - end - Ũ, = _pbug_newdirs(C⁰, Ĉ¹, alg.alg_orth) - As[i] = catdomain(C⁰, Ũ) - GLnew = _pbug_mixedenv_step(GLmix, H, i, ψ₀.AL[i], Ũ) - i == L - 1 && break # the full mixed environment is no longer needed - GLmix = _pbug_mixedenv_step(GLmix, H, i, ψ₀.AL[i], As[i]) - end - # root: the amplitude is carried once, by the evolved center and its coupling row - C̃L = scale(_pbug_coupling_hamiltonian(GLnew, H, L, envs₀, ψ₀)(ψ₀.AC[L]), dt′) - As[L] = _pbug_stack_child(C̄L, C̃L) - - return FiniteMPS( - convert(Vector{typeof(As[1])}, As); overwrite = true, normalize = imaginary_evolution - ) -end - -# Mixed ⟨augmented|H|old⟩ left environments: initial (trivial-bond) environment and one-site -# transfer step, with the same operator dispatch as the effective-Hamiltonian constructors. -_pbug_mixedenv_init(H, envs, ψ₀) = leftenv(envs, 1, ψ₀) -function _pbug_mixedenv_init(H::MultipliedOperator, envs, ψ₀) - return _pbug_mixedenv_init(H.op, envs, ψ₀) -end -function _pbug_mixedenv_init(H::LazySum, envs::MultipleEnvironments, ψ₀) - return map((o, e) -> _pbug_mixedenv_init(o, e, ψ₀), H.ops, envs.envs) -end - -_pbug_mixedenv_step(GL, H, i, above, below) = GL * TransferMatrix(above, H[i], below) -function _pbug_mixedenv_step(GL, H::MultipliedOperator, i, above, below) - return _pbug_mixedenv_step(GL, H.op, i, above, below) -end -function _pbug_mixedenv_step(GLs::Vector, H::LazySum, i, above, below) - return map((gl, o) -> _pbug_mixedenv_step(gl, o, i, above, below), GLs, H.ops) -end - -# One-site effective derivative with the mixed new-direction rows as left environment and the -# frozen old right environment: applied to the center `AC[i]` this yields the first-order coupling -# block `C̃ᵢ` (Alg. 4's `h·F(Y_τ⁰) ×ⱼ U⁰* ×ᵢ Ũ¹*`). -function _pbug_coupling_hamiltonian(GL, H, i, envs, ψ₀) - return MPO_AC_Hamiltonian(GL, H[i], rightenv(envs, i, ψ₀)) -end -function _pbug_coupling_hamiltonian(GL, H::MultipliedOperator, i, envs, ψ₀) - return MultipliedOperator(_pbug_coupling_hamiltonian(GL, H.op, i, envs, ψ₀), H.f) -end -function _pbug_coupling_hamiltonian(GLs::Vector, H::LazySum, i, envs::MultipleEnvironments, ψ₀) - Hs = map((gl, o, e) -> _pbug_coupling_hamiltonian(gl, o, i, e, ψ₀), GLs, H.ops, envs.envs) - elT = Union{D, MultipliedOperator{D}} where {D <: DerivativeOperator} - return LazySum{elT}(Hs) -end - -# new bond directions: the component of the evolved (stacked) candidate orthogonal to the old -# basis, re-orthonormalized. Returns the isometry `Ũ` and the new bond space. -function _pbug_newdirs(AL, Cevo, alg_orth = Defaults.alg_orth()) - N = left_null(AL) - g = N' * Cevo - Q, _ = left_orth(g; alg = alg_orth) - Ũ = N * Q - return Ũ, domain(Q) -end - -# stack two MPS tensors along the child (left-virtual) bond: `[top; bot]`, doubling that bond -_pbug_stack_child(top, bot) = - _transpose_front(catcodomain(_transpose_tail(top), _transpose_tail(bot))) - -_pbug_truncates(alg::ParallelBUG) = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) - -# Cut the augmented bonds back down. A truncating `trscheme` selects rank-adaptivity; the default -# `notrunc()` restores the pre-step virtual space of every bond (fixed-rank parallel BUG). -# Environments self-heal lazily for the changed bonds. -function _pbug_truncate!(ϕ, alg::ParallelBUG, Vs; normalize::Bool = false) - if _pbug_truncates(alg) - changebonds!(ϕ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) - else - # per-bond fixed-rank restore (mirrors the `SvdCut` sweep with a per-bond `trscheme`) - for i in (length(ϕ) - 1):-1:1 - U, S, Vᴴ = svd_trunc(ϕ.C[i]; trunc = truncspace(Vs[i]), alg = alg.alg_svd) - ϕ.AC[i] = (ϕ.AL[i] * U, S) - ϕ.AC[i + 1] = (S, _transpose_front(Vᴴ * _transpose_tail(ϕ.AR[i + 1]))) - end - normalize && normalize!(ϕ) - end - return ϕ -end - -# overwrite the internal representation of `ψ` with that of `ϕ` (same length, possibly new bonds) -function _pbug_overwrite!(ψ::FiniteMPS, ϕ::FiniteMPS) - for f in (:ALs, :ARs, :ACs, :Cs) - copyto!(getfield(ψ, f), getfield(ϕ, f)) - end - return ψ -end - -# copying version (mirrors `bug.jl`/`tdvp.jl`) -function timestep( - ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, - alg::ParallelBUG, envs::AbstractMPSEnvironments...; - imaginary_evolution::Bool = false, kwargs... - ) - isreal = (scalartype(ψ) <: Real && !imaginary_evolution) - ψ′ = isreal ? complex(ψ) : copy(ψ) - if length(envs) != 0 && isreal - @warn "Currently cannot reuse real environments for complex evolution" - envs′ = environments(ψ′, H, ψ′) - elseif length(envs) == 1 - envs′ = only(envs) - else - @assert length(envs) == 0 "Invalid signature" - envs′ = environments(ψ′, H, ψ′) - end - return timestep!(ψ′, H, time, timestep, alg, envs′; imaginary_evolution, kwargs...) -end From 296863d201a82dd3b8371aae72310011968b95dc Mon Sep 17 00:00:00 2001 From: lkdvos Date: Fri, 10 Jul 2026 14:08:56 -0400 Subject: [PATCH 15/23] BUG: reconcile ParallelBUG with the reference implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compared against Ceruti et al. 2024 and the authors' reference (github.com/JonasKu/Publication-Parallel-BUG-for-TTNs). The core assembly matches on every load-bearing point; this lands the concrete gaps found: - fix time-dependent coupling: `_pbug_coupling_hamiltonian` was applied with the one-arg form (a MethodError for a genuine TimedOperator); apply two-arg at the midpoint `t + dt/2` (the anchor `integrate` freezes coefficients at). - thread phase-1's L independent local solves via `tmap!` gated on `Defaults.scheduler[]`; warm the lazily-mutating finite environments serially first (`_pbug_warmup_envs!`, dispatching through MultipleEnvironments). - add the local error estimator `η = ‖C̃‖/dt` (eq. 6) and an opt-in rank-saturation step-rejection retry (fields `c`, `maxiter_rejection`). - type-stable assembly (`As`/`Cevo` no longer `Vector{Any}`). - tests: TimedOperator, step rejection, linear error accumulation. Deferred (documented in research/PARALLELBUG_STATUS.md): the second-order augmented-Galerkin variant and the η-threshold / basis-enrichment retry. Co-Authored-By: Claude Opus 4.8 (1M context) --- research/PARALLELBUG_STATUS.md | 51 ++++++++++++--- src/algorithms/timestep/bug.jl | 112 +++++++++++++++++++++++++++------ test/algorithms/parallelbug.jl | 70 +++++++++++++++++++++ 3 files changed, 204 insertions(+), 29 deletions(-) diff --git a/research/PARALLELBUG_STATUS.md b/research/PARALLELBUG_STATUS.md index ba93f3593..932fdc44c 100644 --- a/research/PARALLELBUG_STATUS.md +++ b/research/PARALLELBUG_STATUS.md @@ -52,14 +52,47 @@ Two changes relative to the first WIP driver: "full-environment effective Hamiltonian trilemma" (`assembly A/B/C`): the phase only ever enters the state through the root blocks. +## Reconciliation with the reference (2026-07-10) + +Compared against the authors' reference `github.com/JonasKu/Publication-Parallel-BUG-for-TTNs` +(MATLAB `Section5.1`, Julia `Section5.2/5.3`). The core assembly matches on every load-bearing +point (frozen-`t₀` independent solves; old-first `[U₀│Ũ]` QR augmentation; first-order coupling with +zeroed new–new corners; amplitude carried once at the root; SVD truncation). Changes landed this +session (all 41 tests green, incl. threaded 4-thread run): + +* **Threading (done)**: phase-1's `L` solves swapped from `map` onto `tmap!` gated on + `Defaults.scheduler[]` (mirrors `tdvp.jl`). Finite envs mutate on lazy recompute, so they are + warmed serially (`_pbug_warmup_envs!`, dispatching through `MultipleEnvironments`) before the + parallel region. +* **Time-dependent coupling (fixed)**: `_pbug_coupling_hamiltonian(...)` was applied with the + one-arg form — a `MethodError` for a genuine `TimedOperator` (no one-arg apply). Now applied at the + midpoint `t + dt/2` (the anchor `integrate` freezes coefficients at). New `TimedOperator` smoke test. +* **Error estimator (done)**: `η = ‖C̃‖/dt` accumulated in `_pbug_assemble` — the coupling blocks + `C̃ᵢ` ARE the frozen derivative projected onto the new directions (Ceruti et al. 2024, eq. 6; + cf. `eta_check.m`, whose `ttm(F_root, {Ũ-projectors})` is exactly this projection). Logged `@debug`. +* **Step rejection (partial, opt-in `maxiter_rejection > 0`)**: the **rank-saturation** trigger + (`rejection_check.m`) — if a truncating step keeps the full doubled space on a bond, recompute as + two half-steps (sub-stepping lowers the per-step normal component so one doubling suffices). + Bounded recursion. Fields `c` (default 10.0) and `maxiter_rejection` (default 0) added. +* **Type stability / cleanups (done)**: `Vector{Any}`→concrete `As`/`Cevo`. + ## Remaining work (why still experimental) -1. **Threading**: phase 1's `L` local exponential solves all read from the frozen snapshot and are - mutually independent — swap the `map` onto `@sync`/`Threads.@spawn` gated on - `Defaults.scheduler[]` (mirror `tdvp.jl`'s `InfiniteMPS` branch). Phase 2 is a cheap sequential - sweep (QRs + one derivative apply per site), as in the paper. -2. **Step rejection** (paper §3.3): `η = ‖Ũ₁'F₀Ṽ₁‖`, reject & recompute on the augmented bases if - `h·η > c·ϑ` or the truncation saturates the doubling cap; needs fields `c` (default 10) and - `maxiter_rejection`. -3. Minor: `timestep!`'s `envs` argument is currently only used for `L == 1`; the frozen snapshot - environments are recomputed each step (could be reused when the caller's `envs` are current). +1. **η-threshold rejection trigger**: `η` is computed but only the rank-saturation trigger drives + the retry. Wiring `dt·η > c·ϑ` needs a scheme-generic way to extract the tolerance `ϑ` from an + arbitrary `trscheme` (trivial for `truncerror`, unclear in general). The current retry also uses + sub-stepping rather than the reference's **basis-enrichment** recompute (re-embed `ψ₀` in the + augmented 2r basis, re-assemble → 4r); enrichment keeps the same `dt` and is the true + rank-adaptive mechanism, but needs the matrix-paper [4] retry semantics + numerical validation + (the caterpillar has no dropped corner until `L ≥ 3`, so there is no cheap 2-site oracle). +2. **Second-order variant** (`TTN_integrator_parallel_2nd_order_nonglobal.m` + `RK_2`): the reference + is **NOT** a Strang composition — it is an **augmented-Galerkin** scheme that *keeps* the new–new + coupling corners (a `3r` core: old block, new–old coupling `C̄`, and the new–new block `Cᵢ`), + solving the enlarged core ODE. For MPS this means retaining (not discarding via the R-factor) the + new–new corner amplitude at interior nodes — effectively a parallel analogue of the sequential + `BUG`'s second order. Substantial; recommend as a focused follow-up. Same subtle corner + machinery as (1)'s enrichment, so validate them together against an `L ≥ 3` reference. +3. Minor: `timestep!`'s `envs` argument is still only used for `L == 1`; the frozen-snapshot + environments are recomputed each step. Reusing the caller's `envs` is blocked by the frozen-copy + design (`ψ₀ = copy(ψ)` ⇒ identity-keyed envs cannot match); evolving from `ψ` directly (it is not + mutated before the final overwrite) would enable reuse but trades away the copy's isolation. diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 9d693628f..b1acc3b11 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -180,11 +180,17 @@ steps, scale `ϑ` with `dt` for a fixed target accuracy. Any truncating `trschem state; the default `notrunc()` restores the pre-step virtual spaces after every step (fixed-rank parallel BUG). +Setting `maxiter_rejection > 0` enables **step rejection**: when a truncating step keeps the *full* +augmented (doubled) space on some bond — a single doubling was not enough to resolve `trscheme` — +the step is recomputed as two half-steps, so a smaller per-step normal component `η` fits within one +doubling per sub-step (cf. `rejection_check` of Ceruti et al. 2024). The local error estimator +`η = ‖C̃‖/dt` (the frozen derivative projected onto the new directions, their eq. 6) is logged at +`@debug` level each step. + !!! warning "Experimental" - This integrator is **work in progress**: the parallel-in-time structure is implemented (all - local solves read from one frozen snapshot), but the local solves are currently executed - serially, and the step-rejection criterion of Ceruti et al. (2024) is not yet implemented. - The API and behaviour may change. + This integrator is **work in progress**. The API and behaviour may change. The step-rejection + trigger is the rank-saturation criterion; the `η`-threshold trigger `dt·η > c·ϑ` and the + second-order augmented-Galerkin variant of Ceruti et al. (2024) are not yet implemented. !!! note Real-time evolution does not normalize the resulting state: neither the augmentation nor the @@ -221,6 +227,12 @@ struct ParallelBUG{A, O, T, S, F} <: Algorithm "algorithm used for the singular value decomposition" alg_svd::S + "safety constant `c` in the step-rejection threshold `h·η > c·ϑ` (paper value ≈ 10)" + c::Float64 + + "maximum number of rejection recomputes per step (0 disables step rejection)" + maxiter_rejection::Int + "callback function applied after each iteration, of signature `finalize(iter, ψ, H, envs) -> ψ, envs`" finalize::F end @@ -228,9 +240,13 @@ function ParallelBUG(; integrator = Defaults.alg_expsolve(), tolgauge = Defaults.tolgauge, gaugemaxiter = Defaults.maxiter, alg_orth = Defaults.alg_orth(), trscheme = notrunc(), alg_svd = Defaults.alg_svd(), + c = 10.0, maxiter_rejection = 0, finalize = Defaults._finalize ) - return ParallelBUG(integrator, tolgauge, gaugemaxiter, alg_orth, trscheme, alg_svd, finalize) + return ParallelBUG( + integrator, tolgauge, gaugemaxiter, alg_orth, trscheme, alg_svd, + c, maxiter_rejection, finalize + ) end function timestep!( @@ -248,17 +264,45 @@ function timestep!( return ψ, envs end + truncates = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) # remember the pre-step virtual spaces for the fixed-rank (`notrunc`) restore Vs = [right_virtualspace(ψ, b) for b in 1:(L - 1)] - ϕ = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) + ϕ, ηh = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) + augVs = [right_virtualspace(ϕ, b) for b in 1:(L - 1)] # the (doubled) augmented spaces _pbug_truncate!(ϕ, alg, Vs; normalize = imaginary_evolution) + # step rejection (opt-in via `maxiter_rejection > 0`): if the truncation kept the *full* augmented + # space on some bond (a single doubling was not enough to resolve `trscheme`), the step is + # under-resolved (Ceruti et al. 2024; cf. `rejection_check.m`). Recompute it as two half-steps — + # sub-stepping lowers the per-step normal component `η = ηh/dt`, so one doubling per sub-step + # suffices. Bounded by `maxiter_rejection`, after which the saturated step is accepted. + if truncates && alg.maxiter_rejection > 0 + saturated = any(1:(L - 1)) do b + return right_virtualspace(ϕ, b) == augVs[b] && augVs[b] != Vs[b] + end + @debug "ParallelBUG step" ηh saturated + if saturated + alg′ = _pbug_with_rejections(alg, alg.maxiter_rejection - 1) + timestep!(ψ, H, t, dt / 2, alg′; imaginary_evolution) + timestep!(ψ, H, t + dt / 2, dt / 2, alg′; imaginary_evolution) + return ψ, environments(ψ, H, ψ) + end + end + # mutate `ψ` in place to become the assembled state (the generic loop reuses the object) _pbug_overwrite!(ψ, ϕ) return ψ, environments(ψ, H, ψ) end +# rebuild a `ParallelBUG` with a reduced rejection budget (the struct is immutable) +function _pbug_with_rejections(alg::ParallelBUG, n::Int) + return ParallelBUG( + alg.integrator, alg.tolgauge, alg.gaugemaxiter, alg.alg_orth, + alg.trscheme, alg.alg_svd, alg.c, n, alg.finalize + ) +end + # Assemble the augmented (pre-truncation) parallel-BUG state from a single frozen `t₀` snapshot, # following Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4 specialized to the caterpillar tree # rooted at site `L`. Two phases: @@ -283,29 +327,43 @@ function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Boo envs₀ = environments(ψ₀, H, ψ₀) dt′ = imaginary_evolution ? -dt : -im * dt + # Pre-populate the frozen environments serially: `leftenv`/`rightenv` on `FiniteEnvironments` + # lazily *mutate* their cache (finite_envs.jl), so the parallel phase-1 solves below must only + # ever read them. After this pass every `AC_hamiltonian(i)` reads a warm, read-only cache. + _pbug_warmup_envs!(envs₀, L, ψ₀) + # phase 1: frozen-snapshot Galerkin evolutions (independent local solves). The interior # K-steps evolve the amplitude-weighted centers `AC[i]` (the paper's `Y_τ⁰ = U_τ⁰S_τ⁰`); only - # their range is kept, so the amplitude they carry is discarded in the orthonormalization. - Cevo = map(1:(L - 1)) do i + # their range is kept, so the amplitude they carry is discarded in the orthonormalization. All + # `L` solves read the read-only snapshot and are mutually independent — the parallel-in-time + # structure; `tmap!` threads them according to `Defaults.scheduler[]` (as in `tdvp.jl`). + scheduler = Defaults.scheduler[] + Cevo = Vector{typeof(ψ₀.AC[1])}(undef, L) + tmap!(Cevo, 1:L; scheduler) do i return integrate( AC_hamiltonian(i, ψ₀, H, ψ₀, envs₀), ψ₀.AC[i], t, dt, alg.integrator; imaginary_evolution ) end - C̄L = integrate( - AC_hamiltonian(L, ψ₀, H, ψ₀, envs₀), ψ₀.AC[L], t, dt, alg.integrator; imaginary_evolution - ) - - # phase 2: leaves→root augmentation sweep, threading the mixed ⟨augmented|H|old⟩ environments - As = Vector{Any}(undef, L) + C̄L = Cevo[L] + + # phase 2: leaves→root augmentation sweep, threading the mixed ⟨augmented|H|old⟩ environments. + # Alongside, accumulate `ηh = max‖C̃ᵢ‖ = h·η`: each coupling block `C̃ᵢ` is the frozen derivative + # projected onto the new directions (the normal component the old basis misses), so its norm is + # the local error estimator of Ceruti-Kusch-Lubich (2024, eq. 6), specialized to the caterpillar. + As = Vector{typeof(ψ₀.AL[1])}(undef, L) + ηh = zero(real(scalartype(ψ₀))) GLmix = _pbug_mixedenv_init(H, envs₀, ψ₀) # full augmented rows (trivial at the left edge) local GLnew # new-direction rows only for i in 1:(L - 1) if i == 1 C⁰, Ĉ¹ = ψ₀.AL[1], Cevo[1] else - # first-order coupling block on the new rows of bond i-1 - C̃ = scale(_pbug_coupling_hamiltonian(GLnew, H, i, envs₀, ψ₀)(ψ₀.AC[i]), dt′) + # first-order coupling block on the new rows of bond i-1. Applied with the two-arg + # (time-dependent) form at the midpoint `t + dt/2`, matching the anchor `integrate` + # freezes the coefficients at (integrators.jl); a `TimedOperator` has no one-arg apply. + C̃ = scale(_pbug_coupling_hamiltonian(GLnew, H, i, envs₀, ψ₀)(ψ₀.AC[i], t + dt / 2), dt′) + ηh = max(ηh, norm(C̃)) C⁰ = _pbug_stack_child(ψ₀.AL[i], zerovector!(similar(C̃))) Ĉ¹ = _pbug_stack_child(Cevo[i], C̃) end @@ -316,12 +374,26 @@ function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Boo GLmix = _pbug_mixedenv_step(GLmix, H, i, ψ₀.AL[i], As[i]) end # root: the amplitude is carried once, by the evolved center and its coupling row - C̃L = scale(_pbug_coupling_hamiltonian(GLnew, H, L, envs₀, ψ₀)(ψ₀.AC[L]), dt′) + C̃L = scale(_pbug_coupling_hamiltonian(GLnew, H, L, envs₀, ψ₀)(ψ₀.AC[L], t + dt / 2), dt′) + ηh = max(ηh, norm(C̃L)) As[L] = _pbug_stack_child(C̄L, C̃L) - return FiniteMPS( - convert(Vector{typeof(As[1])}, As); overwrite = true, normalize = imaginary_evolution - ) + return FiniteMPS(As; overwrite = true, normalize = imaginary_evolution), ηh +end + +# Warm the lazily-cached frozen environments serially so the parallel phase-1 solves only read +# them: `leftenv`/`rightenv` on `FiniteEnvironments` mutate their cache. A `LazySum` yields a +# `MultipleEnvironments`, so recurse into its per-summand `FiniteEnvironments`. +function _pbug_warmup_envs!(envs::FiniteEnvironments, L, ψ₀) + for i in 1:L + leftenv(envs, i, ψ₀) + rightenv(envs, i, ψ₀) + end + return envs +end +function _pbug_warmup_envs!(envs::MultipleEnvironments, L, ψ₀) + foreach(e -> _pbug_warmup_envs!(e, L, ψ₀), envs.envs) + return envs end # Mixed ⟨augmented|H|old⟩ left environments: initial (trivial-bond) environment and one-site diff --git a/test/algorithms/parallelbug.jl b/test/algorithms/parallelbug.jl index aa1843f28..b8e7668e3 100644 --- a/test/algorithms/parallelbug.jl +++ b/test/algorithms/parallelbug.jl @@ -115,6 +115,20 @@ using Random @test (3 + 1.55 - 0.1) * E₀ ≈ E atol = 1.0e-2 end + # 6b. TimeDependent operator smoke test: exercises the `TimedOperator` coupling path (which + # has no one-arg apply). A constant-in-`t` coefficient makes midpoint-freezing exact, so + # evolving the time-dependent `Ht` at `t=1.0` must match evolving the pre-evaluated `Ht(1.0)`. + @testset "TimeDependent LazySum" begin + Ht = MultipliedOperator(H, t -> 4) + MultipliedOperator(H, 1.45) + alg = ParallelBUG(; trscheme = truncerror(; atol = 1.0e-12)) + ψa, envsa = timestep(ψ₀, Ht(1.0), 0.0, dt, alg) + Ea = expectation_value(ψa, Ht(1.0), envsa) + + ψt, envst = timestep(ψ₀, Ht, 1.0, dt, alg) + Et = expectation_value(ψt, Ht(1.0), envst) + @test Ea ≈ Et atol = 1.0e-8 + end + # 7. convergence order: the integrator is documented as (globally) first order in dt; assert at # least that. (At these bond dimensions the augmented spans are near-exact and the measured # slope is ≈ 2, so only a lower bound is imposed.) @@ -169,6 +183,62 @@ using Random @test issorted(errs; rev = true) @test errs[end] < errs[1] / 10 end + + # 9. step rejection: a small-bond start under a tight tolerance + a large `dt` can saturate the + # doubling cap; `maxiter_rejection > 0` then recomputes the step as half-steps. The recompute + # path must run cleanly, keep a normalized state, and never worsen the overlap with the dense + # reference (sub-stepping only refines it). + @testset "step rejection" begin + Random.seed!(7) + Lr = 4 + Hr = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lr)) + Hmat = convert(TensorMap, Hr) + ψr = normalize!(complex(FiniteMPS(rand, Float64, Lr, ℙ^2, ℙ^2))) # bond dim 2 (small) + ψvec = convert(TensorMap, ψr); ψvec /= norm(ψvec) + dtbig = 0.3 + ref = exp(-im * Hmat * dtbig) * ψvec + + tol = truncerror(; atol = 1.0e-6) + ψno, = timestep(ψr, Hr, 0.0, dtbig, ParallelBUG(; trscheme = tol, maxiter_rejection = 0)) + ψrej, = timestep(ψr, Hr, 0.0, dtbig, ParallelBUG(; trscheme = tol, maxiter_rejection = 4)) + outno = convert(TensorMap, ψno); outno /= norm(outno) + outrej = convert(TensorMap, ψrej); outrej /= norm(outrej) + errno = 1 - abs(dot(outno, ref)) + errrej = 1 - abs(dot(outrej, ref)) + @info "ParallelBUG step rejection" errno errrej + @test errrej ≤ errno + 1.0e-12 # sub-stepping never worsens the accuracy + @test isfinite(errrej) + end + + # 10. first-order error accumulation (Ceruti et al. 2024, Thm 4.5: `‖error‖ ≲ c·n·ϑ`). At fixed + # `dt` and `ϑ` the global error grows ~linearly in the number of steps `n`. (A `dt`-refinement + # slope is ill-posed here: at fixed `ϑ`, halving `dt` doubles `n` and thus the accumulated + # truncation error, so refining `dt` need not converge — hence this per-`n` test instead.) + @testset "linear error accumulation" begin + Random.seed!(13) + Le = 6 + He = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Le)) + Hmat = convert(TensorMap, He) + ψe = normalize!(complex(FiniteMPS(rand, Float64, Le, ℙ^2, ℙ^2))) + ψvec = convert(TensorMap, ψe); ψvec /= norm(ψvec) + δt = 0.02 + alg = ParallelBUG(; trscheme = truncerror(; atol = 1.0e-4)) + ns = [5, 10, 20] + # state 2-norm error (phase-aligned), the quantity the `c·n·ϑ` bound controls — the overlap + # *infidelity* `1-|⟨·⟩|` is its square, so it would grow ~n² and must not be used here. + errs = map(ns) do n + ref = exp(-im * Hmat * (n * δt)) * ψvec + ψc = copy(ψe) + for k in 0:(n - 1) + ψc, = timestep(ψc, He, k * δt, δt, alg) + end + out = convert(TensorMap, ψc); out /= norm(out) + return sqrt(2 * (1 - abs(dot(out, ref)))) + end + @info "ParallelBUG error accumulation" ns errs + @test issorted(errs) # error grows monotonically with the step count + @test 2.5 * errs[1] < errs[3] < 6 * errs[1] # ~linear in n (5→20 = 4×), not saturating/quadratic + end end # Charge-sector coverage: a fixed-rank symmetric step must preserve the total charge and the graded From 8aa7455fb6ed990783bf6fddcc7dc0cb579dfa5f Mon Sep 17 00:00:00 2001 From: lkdvos Date: Fri, 10 Jul 2026 14:18:01 -0400 Subject: [PATCH 16/23] BUG: record the exact second-order ParallelBUG algorithm (design note) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverse-engineered the reference `TTN_integrator_parallel_2nd_order_nonglobal.m` (augmented-Galerkin: keeps the new-new corner `Ci = Δt·F` projected onto the new directions, a 3r core — NOT a Strang composition) and derived the MPS caterpillar mapping. Records why it is a substantial port (Tucker-tree core-at-every-node vs. MPS root-only amplitude ⇒ needs a full augmented-basis Galerkin sweep) and how to validate it (rank-limited err(order=2) < err(order=1); dt-slope ≈ 2). Co-Authored-By: Claude Opus 4.8 (1M context) --- research/PARALLELBUG_design.md | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/research/PARALLELBUG_design.md b/research/PARALLELBUG_design.md index 52ea45fce..7cf860675 100644 --- a/research/PARALLELBUG_design.md +++ b/research/PARALLELBUG_design.md @@ -121,6 +121,41 @@ into `Ĉ¹ᵢ = [C̄¹ᵢ; C̃ᵢ]` *before* orthonormalizing, so that deep new root; the root tensor `[C̄_L(t₁); C̃_L]` then carries all amplitude and first-order content. See `PARALLELBUG_STATUS.md` for the implemented recipe and the numerical gates. +## 2d. SECOND-ORDER variant (from the reference `TTN_integrator_parallel_2nd_order_nonglobal.m`) + +The reference achieves second order **not** by Strang composition but by an **augmented-Galerkin** +scheme that *keeps the new–new coupling corner* the first-order method drops. Exact recipe (verbatim +from the MATLAB, per node with `m` children): + +1. **Pre-step**: build the augmented child bases `U0_hat{jj}` (same QR construction as first order) + and overlap matrices `Mi{jj} = Q0ᵢ' (⊗_{i≠jj} Y0ᵢ'U0_hat_i) Q0ᵢ`. +2. **Subflow Φᵢ** (per child, parallel): K-step evolves `Y0ᵢ·Mi{i}` (leaf: `RK_4`; subtree: recurse); + augment the child basis old-first `[Y0{i} Y1_i]` via QR, then **zero-pad to `3·r`** and take + `U_tilde{i} = ` columns `r+1:end` (the ≤ `2r` new directions). +3. **Subflow Ψ** (core): `C0 = ttm(Y0{end}, Mi, 1:m)`; evolve the core `C1_bar = + RK_4_tensor(C0, func_ODE, U0_hat, …)` where `func_ODE` = `F` projected onto the **old** bases `U0` + (a Galerkin K-step of the core). +4. **Augmentation** (the second-order piece): `F{end} ← (t1-t0)·F{end}`; for each mode `jj`, + `Ci = ttm(F{end}, dum, 1:m)` with `dum{i} = Mat0Mat0(U_tilde{i}, F{i})` for `i=jj` and + `Mat0Mat0(U0_hat{i}, F{i})` otherwise — i.e. `Δt·F` projected onto the **new** directions on mode + `jj` and the augmented bases elsewhere. This `Ci` is placed into rows `r+1:3r` of the core: + `tmp(rr(jj)+1:3*rr(jj), :) = tenmat(Ci,jj,vv)`, `ss(jj)=3*ss(jj)`. **The block the first-order + method leaves at 0 is now filled by `Ci`.** (`Ci` is exactly the quantity whose norm is the + estimator `η`; keeping it is what raises the order.) +5. Truncate the `3r` bonds back down (elsewhere). + +**MPS caterpillar mapping (`m = 1` per node → the loops collapse):** each interior connecting tensor +`AC[i]` gets a **`3r`** child bond — old `r`, plus the ≤`2r` new directions from `[old | K-step | Ci]` +— with the coupling `Ci = dt·⟨Ũᵢ₋₁|H|ψ₀⟩` **kept in the core** instead of only informing `Ũ`'s range. +Concretely this means the interior tensors are no longer pure isometries: the new–new block carries +amplitude, so the assembled state must be re-gauged (`FiniteMPS` from the raw tensors handles this). + +**Implementation status / risk**: this is a genuine cross-framework port — the reference is a +Tucker-tree (cell arrays, `tenmat`/`ttm`, explicit `Mi`/`Q0_i`), MPSKit is `TensorMap` MPS. Validation +has no cheap oracle (the caterpillar 2-site step is exact; dropped corners appear only at `L ≥ 3`), +so it must be validated behaviourally: in a **rank-limited** regime `err(order=2) < err(order=1)` at +matched `dt`/`ϑ`, and the log–log `dt`-slope ≈ 2. Expose via an `order::Int` field (default 1). + ## 3. Key design decisions (recorded) 1. **New name, not a flag on `BUG`.** A separate `ParallelBUG <: Algorithm` struct rather than From 77267f7de3d2d2e044943d421904398084dce974 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Fri, 10 Jul 2026 14:43:46 -0400 Subject: [PATCH 17/23] BUG: record negative result for the second-order ParallelBUG attempt Implemented and rigorously tested the tractable route to a second-order variant (first-order 2r augmented basis + Galerkin core evolution on the augmented state's own environments). It is exact at 2 sites and ~2x more accurate than first order, but the measured LOCAL one-step error slope is ~2, not 3 -- a better-constant first-order scheme, not genuine second order -- so it was reverted rather than shipped mislabeled. A Galerkin core on the frozen 2r basis caps the local error at O(dt^2); genuine second order needs the reference's 3r augmentation that adds the new-new corner (Ci) directions to the basis and keeps their amplitude. Documented as the remaining path in the design note. Co-Authored-By: Claude Opus 4.8 (1M context) --- research/PARALLELBUG_design.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/research/PARALLELBUG_design.md b/research/PARALLELBUG_design.md index 7cf860675..014ce33ec 100644 --- a/research/PARALLELBUG_design.md +++ b/research/PARALLELBUG_design.md @@ -153,8 +153,21 @@ amplitude, so the assembled state must be re-gauged (`FiniteMPS` from the raw te **Implementation status / risk**: this is a genuine cross-framework port — the reference is a Tucker-tree (cell arrays, `tenmat`/`ttm`, explicit `Mi`/`Q0_i`), MPSKit is `TensorMap` MPS. Validation has no cheap oracle (the caterpillar 2-site step is exact; dropped corners appear only at `L ≥ 3`), -so it must be validated behaviourally: in a **rank-limited** regime `err(order=2) < err(order=1)` at -matched `dt`/`ϑ`, and the log–log `dt`-slope ≈ 2. Expose via an `order::Int` field (default 1). +so it must be validated behaviourally by the **local one-step error order**: `‖step − exact‖ ∝ dt²` +for first order, `∝ dt³` for a genuine second order. + +**2026-07-10 attempt + NEGATIVE RESULT (not landed).** Implemented the tractable route — build the +first-order `2r` augmented basis `Û = [old | Ũ]`, then a Galerkin evolution of the *root* center on +the augmented state's own environments `⟨Û|H|Û⟩` (embedding `ψ₀` via a chain-transport `T`, evolving +with `AC_hamiltonian` from `t₀`). It is **exact at 2 sites** and consistently **~2× more accurate** +than first order in a rank-limited regime — but the measured **local slope is ≈ 2, not 3** (o1: +1.98/1.99/1.97; o2: 1.97/1.96/1.88 at `dt = 0.04→0.005`). So it is only a better-constant *first*-order +scheme, **not** second order, and was reverted rather than shipped mislabeled. Diagnosis: a Galerkin +core on the *frozen* first-order `2r` basis caps the local error at `O(dt²)` — the basis misses the +`O(dt²)` corner directions. Genuine second order needs the reference's **`3r` augmentation that adds +the `Ci` (new–new corner) directions to the basis and keeps their amplitude** (step 4 above), i.e. a +second family of new directions beyond `Ũ`, re-gauged. That is the remaining work for a true +second-order `ParallelBUG`; the sequential [`BUG`] already provides a second-order option meanwhile. ## 3. Key design decisions (recorded) From 6cce3fa803b6df2a52a66cdd8ee65519f884586a Mon Sep 17 00:00:00 2001 From: lkdvos Date: Sun, 12 Jul 2026 10:07:59 -0400 Subject: [PATCH 18/23] BUG: implement second-order ParallelBUG2 (Kusch 2024 Variant 2) Replace the L>=3 throw with the genuine second-order assembly, specialized to the caterpillar rooted at the last site: enrich both bond bases to rank 2r with one H*psi0 application (Ubar0/Vbar0, folding the enriched environments by explicit transfer matrices so the zero-weight directions never collapse under canonicalization), run the 2r Galerkin K-steps freezing the enriched right basis Vbar0, and transport the evolved-amplitude coupling R = Utilde2' * Chat to the root through the frozen right isometries. The root core is the exact matrix S-hat assembly (the 2r Galerkin S-bar1 in the Ubar0 rows, the coupling in the Utilde2 rows, new-new corner zero), giving local error O(dt^3) -- uniform log-log slope ~= 3 vs the first-order ~= 2. Replaces the superseded _pbug_preaugment with _pbug2_assemble_core and its helpers. Swaps the ParallelBUG2 @test_throws for the decisive slope-~=3 gate at L=4,5 (full P^8) and L=6 (reduced P^4), plus energy/phase, TDVP agreement, imaginary-time lowering, and Z2 charge preservation. The first-order ParallelBUG and sequential BUG suites are unchanged and green. Plain Hamiltonians only (no LazySum) so far; still experimental. Co-Authored-By: Claude Opus 4.8 (1M context) --- research/PARALLEL_BUG_second_order_status.md | 186 ++++++++++++ src/MPSKit.jl | 2 +- src/algorithms/timestep/bug.jl | 287 ++++++++++++++++++- test/algorithms/parallelbug.jl | 165 +++++++++++ 4 files changed, 630 insertions(+), 10 deletions(-) create mode 100644 research/PARALLEL_BUG_second_order_status.md diff --git a/research/PARALLEL_BUG_second_order_status.md b/research/PARALLEL_BUG_second_order_status.md new file mode 100644 index 000000000..ddeea1265 --- /dev/null +++ b/research/PARALLEL_BUG_second_order_status.md @@ -0,0 +1,186 @@ +# Second-order parallel BUG (`ParallelBUG2`) — status and diagnosis + +## RESOLVED (2026-07-11): genuine second order — faithful Kusch Variant 2 + +`ParallelBUG2` clears the decisive local-order gate with a **uniform** single-step slope **≈ 3** in `dt` +(vs the first-order `ParallelBUG`'s slope ≈ 2). Measured with the shared harness +(`test/algorithms/parallelbug.jl`, TFIsing, `dts = [0.04, 0.02, 0.01, 0.005]`, `trscheme = truncerror(atol=1e-12)`): + +| L | bond | slopes | note | +|---|---|---|---| +| 2 | ℙ^4 | (exact) | machine-exact, no dropped corner | +| 3 | ℙ^8 | (exact) | full-rank enriched basis completes the block | +| 4 | ℙ^8 | ≈ 3.0 | genuine second order | +| 5 | ℙ^8 | ≈ 3.0 | genuine second order | +| 6 | ℙ^4 | ≈ 3.0 | unsaturated interior bond (a real dropped corner) | +| 7 | ℙ^4 | ≈ 3.0 | stays second order as the chain grows | + +The construction implemented in `src/algorithms/timestep/bug.jl` (`_pbug2_assemble_core` and helpers): + +1. **Enrich** both the left (`Û0`) and right (`V̂0`) bond bases to rank `2r` with one frozen `H·ψ₀` + application, old-first, with the mixed-env coupling propagating deep directions to the root; fold the + enriched environments `GLhat`/`GRhat` by **explicit transfer matrices** (no `FiniteMPS` round-trip, so + the zero-weight enriched directions never collapse — the route-1 blocker below). +2. **K-step** every center on the enriched environments, **freezing the enriched right basis `V̂0`** + (freezing the *old* right basis gives slope 2 — see route 2). Threaded like the first-order phase-1. +3. **Assemble** leaves→root: interior tensors are the `4r` isometries `[Û0 | Ũ2]` (new directions from the + K-evolved centers), and the **evolved-amplitude coupling `R = Ũ2ᵀĈ`** is transported one site at a time + through the frozen right isometry `V̂0`. This is the ingredient the earlier attempts missed: the + first-order *frozen-derivative* coupling `h·F(Y0)` projected onto `Ũ2` is ≈ 0 (because `Ũ2 ⟂ Û0` and + `Û0` already contains the `H·ψ₀` direction), so it transports no second-order content and the interior + bonds stay first order (slope 2 at `L ≥ 6`). The evolved amplitude `Ũ2ᵀĈ` is the genuine `O(h²)` term, + the caterpillar analogue of the matrix recipe's `Ũ2ᵀK(t1)` block. +4. **Root** core = the exact matrix `Ŝ1` assembly: the rank-`2r` Galerkin `S̄1 = Kevo[L]` in the `Û0` rows + stacked with the transported coupling in the `Ũ2` rows, and the **new–new corner ZERO** (at the root the + right bond is trivial, so the `LᵀṼ2` block vanishes too). This gives the published local error `O(h³)`. + +This matches Kusch (2024) Variant 2 (`4r`, old-first bases, no predicted basis / no transfer matrix `Mi`), +specialized to the caterpillar rooted at the last site. The faithfulness signature is the **uniform** slope 3 +(an earlier hybrid that replaced the root `Ŝ1` with a full `4r` Galerkin over-resolved to slope ≈ 4 at +small L — more accurate, but not the published structure; the exact zeroed-corner assembly removes it). +It clears the full shared verification (2-site exactness, energy/phase, TDVP agreement, imaginary-time +lowering, Z2 charge preservation, serial=threaded). Only plain Hamiltonians are supported so far (no +`LazySum`/time-dependent operators). It is NOT the reference-MATLAB path (that is Variant 1 with predicted +bases `U0_hat` + transfer matrices `Mi` + `3r`); the simpler Variant 2 was implemented as the handoff +recommended. `ParallelBUG2` is still marked experimental. + +The diagnosis of the earlier dead ends is retained below for the record. + +--- + +## Original status (superseded): the dead ends + +One-sentence status (historical): the **first-order** `ParallelBUG` is audited and passes the decisive +local-order slope-≈2 gate at `L = 3, 4`; a genuine **second-order** `ParallelBUG2` is **not** achieved — the +tractable implementation routes measure local slope ≈ 2 (still first order) or break, and the correct +route is a substantial faithful port of the reference tree-tensor-network algorithm that remains to be +done. `ParallelBUG2` is committed as an experimental struct that is exact for `L ≤ 2` and `throw`s for +`L ≥ 3`, so it can never be silently mislabeled as second order. + +This note records what was tried, the measured slopes, why each route fails, and the precise remaining +work, so the port can be resumed without re-deriving the dead ends. + +## The decisive gate + +Single-step local error `‖step(dt) − exp(−iH·dt)ψ₀‖` vs `dt`, log–log slope, full bonds (`ℙ^8`), +transverse-field Ising, `dts = [0.04, 0.02, 0.01, 0.005]`: + +- **First order ⇒ slope ≈ 2**; **genuine second order ⇒ slope ≈ 3**. +- `L = 2` is exact (no dropped corner) and does **not** distinguish the orders — the gate needs `L ≥ 3`. +- Empirically `ParallelBUG` (first order) gives clean slope ≈ 2.0 at `L = 3, 4, 5, 6`. This is the + committed gate in `test/algorithms/parallelbug.jl` (`@testset "local order slope L=$Lc"`). + +## What was tried for second order, and the measured slope + +The correct *mechanism* (Kusch 2024): pre-augment each bond basis with one `H·ψ₀` application **before** +evolving, run the Galerkin center at the enlarged rank on that basis, assemble with the new–new corner +kept zero (now `O(h³)`). Three MPS realizations were attempted: + +1. **Reuse the first-order assembly on an `H·ψ₀`-enriched `FiniteMPS` snapshot `ψ̂0`** (the approved + plan's central idea). **Structurally impossible.** The enriched directions carry *zero weight* + (`ψ̂0 == ψ₀` as a state — verified overlap 1.0), so `FiniteMPS` canonicalization *collapses* them: + the stored `AL[2]` keeps the enriched space (`ℙ^4`) but the mixed-gauge `AC[2]` collapses to `ℙ^2`, + and the first-order `_pbug_assemble_core` then hits a `SpaceMismatch` at `L ≥ 3`. A zero-weight + enriched basis cannot survive a canonical-form round-trip — full stop. + +2. **Explicit enriched-left environment + rank-2r root Galerkin, no 4r augmentation** (materialize a + `FiniteMPS` only from the final, nonzero-weight tensors — avoids the collapse). **Runs and is + stable, but slope ≈ 2.0 at `L = 4`** (`L = 2, 3` come out machine-exact only because the enriched + basis happens to complete the small left block). Enriching the left basis + a root Galerkin *alone* + is a better-constant first-order method — the second-order correction also needs the interior K-step + directions augmented to `4r`/`3r` and the enriched *right* basis frozen in the K-step. + +3. **Predicted-basis Galerkin (`ψhat = compress(H·ψ₀)` to rank r) + transfer matrices + old-first + augmentation.** **Broken: slope ≈ 0** (O(1) error ≈ 0.15, independent of `dt`). Evolving `ψ₀`'s + center on `ψhat`'s environments and transferring back with an ad-hoc overlap matrix does *not* + reproduce the reference's prolongation/restriction: the basis reconciliation between the + predicted-basis core `C1_bar` and the old-first augmented basis is not captured, so the assembled + state is O(1) wrong. + +Route 2's slope ≈ 2 reproduces the prior (reverted, never-committed) attempt's result; route 1 is a new +structural finding; route 3 shows an ad-hoc predicted-basis handling is worse, not better. + +## Why it is hard: the mixed-basis / basis-bookkeeping problem + +The reference `TTN_integrator_parallel_2nd_order_nonglobal.m` (Variant 1, `3r`) does **not** enrich a +single shared bond space. Per node it builds, per child: + +- a rank-`r` **predicted basis** `U0_hat` = orthonormalized range of the `H·ψ₀` image *through the old + environment* `Q0_i` (line 38), **not** the old-first `[old | new]` 2r basis, and **not** a global + SVD-compression of `H·ψ₀`; +- a **transfer matrix** `Mi = ⟨old | U0_hat⟩` (line 49) that maps the old core into the predicted basis; +- the core is evolved from `C0 = ttm(core, Mi)` on the `U0_hat` bases (`func_ODE`), giving `C1_bar` in + the *predicted* basis; +- the augmented basis is `[old | K-evolved]` zero-padded to `3r`, and `C1_bar` is placed in rows + `1:r` with the coupling `Ci` in rows `r+1:3r` (lines 135–164). + +The subtlety that defeats the shortcuts: `C1_bar` lives in the `U0_hat` (predicted) basis while the +augmented columns are the `[old | K-new]` basis; the two are reconciled *implicitly* through `Mi` and +the specific `3r` block placement. Getting local slope 3 requires reproducing this reconciliation +exactly — approximating `U0_hat` (route 3) or skipping the predicted basis (route 2) loses it. + +The left/right asymmetry compounds this: the matrix Variant-2 recipe uses *separate* enriched left +(`Û0`) and right (`V̂0`) bases whose new subspaces differ, so the interior K-step center is a genuinely +*mixed* (rectangular) object — exactly what prolongation/restriction manage and what a naive shared-bond +MPS representation cannot. + +## Remaining work (to resume the port) + +1. **Faithfully port the reference recursion** `TTN_integrator_parallel_2nd_order_nonglobal.m` to the + `FiniteMPS` caterpillar (root at `L`), reusing MPSKit's effective-Hamiltonian environments as the + prolongation/restriction. Build the rank-`r` predicted basis `U0_hat` per bond as the orthonormalized + `H·ψ₀` image *through the old right environment* (not a global compression), the transfer `Mi`, evolve + the core on `U0_hat` from `C0 = ttm(core, Mi)`, and assemble the `3r` core with `C1_bar` in the + old-block rows and the coupling in rows `r+1:3r`, new–new corner zero. Re-gauge only the final + (nonzero-weight) tensors into a `FiniteMPS`. +2. **Gate at `L ≥ 3` (use `L = 4, 5`; `L = 3` full-rank can be exact and masks the corner)** on local + slope ≈ 3 before labeling it second order. Mirror the `ParallelBUG` slope harness already in + `test/algorithms/parallelbug.jl`. +3. Cross-check against a MATLAB trace of the reference on a 3-site chain to pin down the `Mi` / `3r` + basis reconciliation numerically — this is where the shortcuts went wrong and where a from-scratch + derivation is most error-prone. + +## Faithful-port attempt (Variant 2, `4r`) — how far it got and the real blocker + +A second, deeper attempt targeted **Variant 2** (`4r`, [K24] §5.3) specifically because it uses +**old-first enriched bases** `Û0 = orth([U0, F0·V0])`, `V̂0 = orth([V0, F0ᵀ·U0])` and **no predicted +basis / no transfer matrix `Mi`** — so it sidesteps the predicted↔old reconciliation that broke route 3 +above. Progress and findings: + +- **Working building blocks** (verified to run and produce sensible spaces): + - old-first **left** enrichment `Û0[i]` (2r) via the existing `_pbug_newdirs`/`_pbug_stack_child` + machinery fed the `H·ψ₀` image (this is the retained `_pbug_preaugment`); + - old-first **right** enrichment `V̂0[i]` (2r) via `_bug_augment_right` fed the `H·ψ₀` image; + - enriched left/right **environments** by explicit `TransferMatrix` folding through `Û0` / `V̂0` + (`GLhat[i]`, `GRhat[i]`) — no `FiniteMPS` round-trip, so no zero-weight collapse; + - **old→enriched center embedding** via `isometry(enriched_bond ← old_bond)` (old-first ⇒ the + canonical `[I;0]` injection) on the front (left) and tail (right) legs. + - Confirmed the left- and right-enriched bond spaces genuinely **differ** (e.g. L=4, `ℙ^8`: bond 1 + is `ℙ^2` from the left but `ℙ^4` from the right; bond 3 the reverse), matching the matrix recipe's + distinct `Û0`/`V̂0`. The final state uses the **left**-augmented bonds; `V̂0` is only the frozen + right environment for the K-step. + +- **The real blocker (precise):** the enrichment, K-step, and `4r` augmentation **cannot be + precomputed as separate passes and then assembled** — they must be **interleaved in a single + leaves→root sweep with growing bonds**. Precomputing `Û0` at rank `2r` and then augmenting to `4r` + yields **inconsistent chained bonds**: site `i`'s right bond becomes `4r` after augmentation, but + the precomputed `Û0[i+1]` was built with a `2r` left bond, so `As[i+1].left ≠ As[i].right`. The + first-order `_pbug_assemble_core` avoids this by doing the augmentation *inside* the sweep (stacking + each site's old block onto the previous site's already-grown new bond). The second-order sweep must + do the same but additionally (a) enrich each site's old block with `H·ψ₀` on the *current* (already + grown) bond, (b) run the K-step on the mixed `(Û0-left, V̂0-right)` enriched environments, (c) + augment to `4r`, and (d) propagate the off-diagonal coupling blocks (`Ũ2ᵀK`, `Lᵀ Ṽ2`) — all in one + pass. This interleaved, growing-bond, mixed-basis sweep with coupling propagation is the substantial + remaining implementation; it is a rewrite of the assembly sweep, not an add-on. + +## What is delivered now + +- `ParallelBUG` (first order): audited against the §3.1 checklist (interior tensors are + `[AL⁰ | Ũ]` isometries; solves act on the amplitude-weighted center `AC⁰`), and given a **committed + local-order slope-≈2 gate at `L = 3, 4`** — the gate that would catch any regression or a mislabeled + order. No code change was required (the audit confirmed the existing assembly). +- `ParallelBUG2`: struct + keyword constructor + `timestep!`/`timestep` wiring + export, sharing the + `AbstractParallelBUG` supertype and the first-order truncation/rejection helpers. Exact for `L ≤ 2`; + `throw`s an informative `ArgumentError` for `L ≥ 3` (pointing here) so it is never silently first + order. The `_pbug_preaugment` pass (correct `H·ψ₀` left enrichment, verified to preserve the state + to overlap 1.0) is retained as a building block for the port. diff --git a/src/MPSKit.jl b/src/MPSKit.jl index 42026916c..547dd4f2e 100644 --- a/src/MPSKit.jl +++ b/src/MPSKit.jl @@ -35,7 +35,7 @@ export VUMPS, VOMPS, DMRG, DMRG2, IDMRG, IDMRG2, GradientGrassmann export excitations export FiniteExcited, QuasiparticleAnsatz, ChepigaAnsatz, ChepigaAnsatz2 export time_evolve, timestep, timestep!, make_time_mpo -export TDVP, TDVP2, BUG, ParallelBUG, WI, WII, TaylorCluster +export TDVP, TDVP2, BUG, ParallelBUG, ParallelBUG2, WI, WII, TaylorCluster export changebonds, changebonds! export VUMPSSvdCut, OptimalExpand, SvdCut, RandExpand, SketchedExpand export propagator diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index b1acc3b11..f01f87c91 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -149,6 +149,12 @@ end # Parallel BUG # ------------ +# Shared supertype for the parallel-BUG integrators (`ParallelBUG` first order, `ParallelBUG2` +# second order). The assembly/truncation/rejection helpers below dispatch on it, and the +# second-order assembly reuses the first-order core (`_pbug_assemble_core`) on an `H·ψ₀`-enriched +# snapshot, so almost all machinery is shared. +abstract type AbstractParallelBUG <: Algorithm end + """ $(TYPEDEF) @@ -208,7 +214,7 @@ $(TYPEDFIELDS) * Ceruti, Kusch, Lubich & Sulz, *A parallel Basis Update and Galerkin integrator for tree tensor networks*, arXiv:2412.00858 (2024). """ -struct ParallelBUG{A, O, T, S, F} <: Algorithm +struct ParallelBUG{A, O, T, S, F} <: AbstractParallelBUG "algorithm used in the exponential solvers" integrator::A @@ -249,8 +255,100 @@ function ParallelBUG(; ) end +""" +$(TYPEDEF) + +Single site MPS time-evolution algorithm based on the *second-order* variant of the *parallel* +Basis-Update & Galerkin (BUG) integrator for tree tensor networks (Kusch 2024), specialized to the +linear (`FiniteMPS`) tree. It is to [`ParallelBUG`](@ref) what [`TDVP2`](@ref) is to [`TDVP`](@ref): +a separate, more accurate integrator sharing the same interface. + +The genuine second order comes from **pre-augmenting every bond basis to rank `2r` with one +`H·ψ₀` application *before* evolving** (Kusch 2024, "Variant 2" / `4r`). Concretely each left/right +bond isometry is enlarged to `Û₀ = orth([AL⁰ │ (H·ψ₀)])` / `V̂₀ = orth([AR⁰ │ (H·ψ₀)])` (the +directions opened by a single effective-Hamiltonian application to the frozen center), the rank-`2r` +Galerkin K-steps are evolved on those enriched environments *freezing the enriched right basis* +`V̂₀`, and the bonds are augmented to at most `4r`. The `O(dt²)` content that the first-order scheme +discards is transported to the root as the **evolved-amplitude** coupling `R = Ũ₂ᵀĈ` (propagated +through the frozen right isometries). The root core is then assembled exactly as in the matrix +recipe — the rank-`2r` Galerkin `S̄₁` in the `Û₀` rows, the transported coupling (the `Ũ₂ᵀK` block) +in the `Ũ₂` rows, and the "new–new" corner kept **zero** — so the discarded content is `O(dt³)` +rather than the first-order `O(dt²)`, which lifts the local order from `2` to `3`. The interior +tensors stay isometries. A final SVD sweep truncates the (at most quadrupled) bonds back down to +`trscheme`. + +Everything else matches [`ParallelBUG`](@ref): the local problems are solved from a single frozen +`t₀` snapshot (mutually independent, parallelizable), the amplitude and phase enter exactly once at +the root, `notrunc()` gives the fixed-rank integrator, and it advances every tensor *forward* in +time (no backward substep), which suits imaginary-time / dissipative evolution. + +!!! warning "Experimental" + This integrator is **work in progress**. The API and behaviour may change. Its local error is + one order higher than the first-order [`ParallelBUG`](@ref): the single-step error scales as + `O(dt³)` (log–log slope `≈ 3`) versus `O(dt²)` (slope `≈ 2`). Only plain Hamiltonians are + supported so far (no `LazySum`/time-dependent operators). See + `research/PARALLEL_BUG_second_order_status.md` for the construction and its relation to the + reference tree-tensor-network algorithm. + +!!! note + Real-time evolution does not normalize the resulting state: neither the augmentation nor the + truncation normalizes, so the state norm reflects the accumulated truncation error. + Imaginary-time evolution renormalizes after every step, similar to a ground-state search. + +## Fields + +$(TYPEDFIELDS) + +## References + +* Kusch, *Second-order robust parallel integrators for dynamical low-rank approximation*, + arXiv:2403.02834 (2024). +* Ceruti, Kusch, Lubich & Sulz, *A parallel Basis Update and Galerkin integrator for tree tensor + networks*, arXiv:2412.00858 (2024). +""" +struct ParallelBUG2{A, O, T, S, F} <: AbstractParallelBUG + "algorithm used in the exponential solvers" + integrator::A + + "tolerance for gauging algorithm" + tolgauge::Float64 + + "maximal amount of iterations for gauging algorithm" + gaugemaxiter::Int + + "algorithm used to re-orthonormalize the basis after each local update" + alg_orth::O + + "truncation scheme used to cut the augmented bonds back down" + trscheme::T + + "algorithm used for the singular value decomposition" + alg_svd::S + + "safety constant `c` in the step-rejection threshold `h·η > c·ϑ` (paper value ≈ 10)" + c::Float64 + + "maximum number of rejection recomputes per step (0 disables step rejection)" + maxiter_rejection::Int + + "callback function applied after each iteration, of signature `finalize(iter, ψ, H, envs) -> ψ, envs`" + finalize::F +end +function ParallelBUG2(; + integrator = Defaults.alg_expsolve(), tolgauge = Defaults.tolgauge, + gaugemaxiter = Defaults.maxiter, alg_orth = Defaults.alg_orth(), + trscheme = notrunc(), alg_svd = Defaults.alg_svd(), + c = 10.0, maxiter_rejection = 0, + finalize = Defaults._finalize + ) + return ParallelBUG2( + integrator, tolgauge, gaugemaxiter, alg_orth, trscheme, alg_svd, + c, maxiter_rejection, finalize + ) +end + function timestep!( - ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::ParallelBUG, + ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::AbstractParallelBUG, envs::AbstractMPSEnvironments = environments(ψ, H, ψ); imaginary_evolution::Bool = false ) @@ -295,17 +393,188 @@ function timestep!( return ψ, environments(ψ, H, ψ) end -# rebuild a `ParallelBUG` with a reduced rejection budget (the struct is immutable) +# rebuild a parallel-BUG algorithm with a reduced rejection budget (the structs are immutable) function _pbug_with_rejections(alg::ParallelBUG, n::Int) return ParallelBUG( alg.integrator, alg.tolgauge, alg.gaugemaxiter, alg.alg_orth, alg.trscheme, alg.alg_svd, alg.c, n, alg.finalize ) end +function _pbug_with_rejections(alg::ParallelBUG2, n::Int) + return ParallelBUG2( + alg.integrator, alg.tolgauge, alg.gaugemaxiter, alg.alg_orth, + alg.trscheme, alg.alg_svd, alg.c, n, alg.finalize + ) +end + +# Dispatch the assembly on the integrator order. First order assembles directly from the frozen +# snapshot; second order first pre-augments every bond basis to rank `2r` with one `H·ψ₀` +# application, then runs the SAME first-order assembly core on that enriched snapshot — which turns +# the rank-`r` Galerkin into the rank-`2r` Galerkin of Kusch (2024, Variant 2), lifting the local +# order from `2` to `3` while keeping the "new–new" corner zero. +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) + return _pbug_assemble_core(ψ, H, t, dt, alg; imaginary_evolution) +end +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bool = false) + return _pbug2_assemble_core(ψ, H, t, dt, alg; imaginary_evolution) +end + +# ---- second-order (Variant 2) building blocks -------------------------------------------------- + +# old-first LEFT enrichment `Û0[1..L-1]` (rank `2r`) plus the enriched left-environment chain +# `GLhat[1..L]` (`⟨Û0|H|Û0⟩`). This is the frozen `H·ψ₀` pre-augmentation of Kusch (2024, §3.1): at +# bond `i` the old isometry `AL⁰[i]` is enlarged with the range of the frozen derivative image +# `W[i] = (H·ψ₀)ᵢ`, stacked leaves→root with the same mixed `⟨new|H|old⟩` coupling as the first-order +# assembly (span only ⇒ unscaled) so directions opened deep in the chain reach the root. `GLhat` is +# folded by explicit transfer matrices (NO `FiniteMPS` round-trip ⇒ the zero-weight enriched +# directions do not collapse under canonicalization). +function _pbug2_left_enrich(ψ₀, H, envs₀, W, alg_orth) + L = length(ψ₀) + Û0 = Vector{typeof(ψ₀.AL[1])}(undef, L - 1) + GLhat = Vector{Any}(undef, L) + GLhat[1] = leftenv(envs₀, 1, ψ₀) + GLmix = leftenv(envs₀, 1, ψ₀) # mixed ⟨new|H|old⟩ chain (span only, unscaled) + local GLnew + for i in 1:(L - 1) + if i == 1 + C⁰, Ĉ = ψ₀.AL[1], W[1] + else + C̃ = MPO_AC_Hamiltonian(GLnew, H[i], rightenv(envs₀, i, ψ₀))(ψ₀.AC[i]) + C⁰ = _pbug_stack_child(ψ₀.AL[i], zerovector!(similar(C̃))) + Ĉ = _pbug_stack_child(W[i], C̃) + end + Ũ, = _pbug_newdirs(C⁰, Ĉ, alg_orth) + Û0[i] = catdomain(C⁰, Ũ) + GLhat[i + 1] = GLhat[i] * TransferMatrix(Û0[i], H[i], Û0[i]) + GLnew = GLmix * TransferMatrix(ψ₀.AL[i], H[i], Ũ) # ket=old, bra=new + i == L - 1 && break + GLmix = GLmix * TransferMatrix(ψ₀.AL[i], H[i], Û0[i]) # ket=old, bra=enriched + end + return Û0, GLhat +end + +# old-first RIGHT enrichment `V̂0[2..L]` (rank `2r`) plus the enriched right-environment chain +# `GRhat[1..L]` (`⟨V̂0|H|V̂0⟩`), the mirror of `_pbug2_left_enrich`. The interior K-step freezes this +# enriched right basis (freezing the *old* right basis instead only yields local slope 2). +function _pbug2_right_enrich(ψ₀, H, envs₀, W, alg_orth) + L = length(ψ₀) + V̂0 = Vector{typeof(ψ₀.AR[1])}(undef, L) + GRhat = Vector{Any}(undef, L) + GRhat[L] = rightenv(envs₀, L, ψ₀) + GRmix = rightenv(envs₀, L, ψ₀) + local GRnew + for i in L:-1:2 + if i == L + C⁰, Ĉ = ψ₀.AR[L], W[L] + else + C̃ = MPO_AC_Hamiltonian(leftenv(envs₀, i, ψ₀), H[i], GRnew)(ψ₀.AC[i]) + C⁰ = catdomain(ψ₀.AR[i], zerovector!(similar(C̃))) + Ĉ = catdomain(W[i], C̃) + end + V̂, = _pbug2_newdirs_right(C⁰, Ĉ, alg_orth) + V̂0[i] = _transpose_front(catcodomain(_transpose_tail(C⁰), V̂)) + GRhat[i - 1] = TransferMatrix(V̂0[i], H[i], V̂0[i]) * GRhat[i] + GRnew = TransferMatrix(ψ₀.AR[i], H[i], _transpose_front(V̂)) * GRmix # ket=old, bra=new + i == 2 && break + GRmix = TransferMatrix(ψ₀.AR[i], H[i], V̂0[i]) * GRmix # ket=old, bra=enriched + end + return V̂0, GRhat +end + +# right-side analogue of `_pbug_newdirs`: new LEFT-bond (codomain) directions the candidate `cand` +# opens beyond the right-isometry `AR`, returned in the `_transpose_tail` form `r_new ← (P⊗Vr)`. +function _pbug2_newdirs_right(AR, cand, alg_orth = Defaults.alg_orth()) + N = right_null!(_transpose_tail(AR; copy = true)) # Vcomp ← (P⊗Vr) + g = _transpose_tail(cand) * N' # Vl_cand ← Vcomp + _, Q = right_orth(g; alg = alg_orth) # Q: r_new ← Vcomp + return Q * N, nothing # r_new ← (P⊗Vr) +end + +# embed the old center `AC` into the enriched `(VlL ⊗ P) ← Vr` bond spaces with zero weight in the +# new directions (`absorb!` into a larger-space zero tensor handles the old-first block + duals). +function _pbug2_embed(AC, VlL, Vp, Vr) + return absorb!(zerovector!(similar(AC, (VlL ⊗ Vp) ← Vr)), AC) +end + +# Genuine second-order parallel-BUG assembly (Kusch 2024, Variant 2 / `4r`), specialized to the +# caterpillar rooted at site `L`. One frozen `t₀` snapshot drives everything: +# +# 1. Pre-augment the left/right bond bases to rank `2r` with one `H·ψ₀` application (`Û0`, `V̂0`) and +# fold the enriched environments `GLhat`/`GRhat` by explicit transfer (Kusch's O(h²)-capturing +# predictor bases; the `⟨…⟩` collapse of a `FiniteMPS` round-trip is thereby avoided). +# 2. K-step every center on the enriched environments, freezing the enriched right basis `V̂0` +# (mutually independent ⇒ threaded like the first-order phase-1 solves) → `Kevo`. +# 3. A leaves→root sweep builds the interior `4r` isometries `[Û0 | Ũ2]` (new directions from `Kevo`) +# and transports the EVOLVED-amplitude coupling `R = Ũ2ᵀĈ` one site at a time through the frozen +# right isometry `V̂0` — this is what carries the genuine O(h²) content between interior bonds +# (the first-order frozen-derivative coupling is ≈0 against the enriched basis, giving only slope 2). +# 4. The root core is a Galerkin step on the assembled `4r` left basis, which now spans the enriched, +# K-evolved and transported-coupling directions; the "new–new" corner stays implicitly zero (O(h³)). +# The final tensors carry genuine weight, so a `FiniteMPS` is materialized only here (no collapse). +function _pbug2_assemble_core(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bool = false) + L = length(ψ) + ψ.AC[L] # gauge to the root + ψ₀ = copy(ψ) + envs₀ = environments(ψ₀, H, ψ₀) + _pbug_warmup_envs!(envs₀, L, ψ₀) + scheduler = Defaults.scheduler[] + + # frozen `H·ψ₀` images (independent, threaded) + W = Vector{typeof(ψ₀.AC[1])}(undef, L) + tmap!(W, 1:L; scheduler) do i + return AC_hamiltonian(i, ψ₀, H, ψ₀, envs₀)(ψ₀.AC[i]) + end + + Û0, GLhat = _pbug2_left_enrich(ψ₀, H, envs₀, W, alg.alg_orth) + V̂0, GRhat = _pbug2_right_enrich(ψ₀, H, envs₀, W, alg.alg_orth) + + GRof(i) = i < L ? GRhat[i] : rightenv(envs₀, L, ψ₀) + enrL(i) = i == 1 ? left_virtualspace(ψ₀, 1) : + (i <= L - 1 ? space(Û0[i], 1) : only(domain(Û0[L - 1]))) + enrR(i) = i == L ? right_virtualspace(ψ₀, L) : space(V̂0[i + 1], 1) + embed(i) = _pbug2_embed(ψ₀.AC[i], enrL(i), physicalspace(ψ₀, i), enrR(i)) + + # K/S steps on the enriched environments (freeze `V̂0`); independent ⇒ threaded + Kevo = Vector{typeof(ψ₀.AC[1])}(undef, L) + tmap!(Kevo, 1:L; scheduler) do i + return integrate( + MPO_AC_Hamiltonian(GLhat[i], H[i], GRof(i)), embed(i), t, dt, alg.integrator; + imaginary_evolution + ) + end + + # leaves→root assembly with the transported evolved-amplitude coupling + As = Vector{typeof(ψ₀.AL[1])}(undef, L) + ηh = zero(real(scalartype(ψ₀))) + local R + for i in 1:(L - 1) + if i == 1 + C⁰, Ĉ¹ = Û0[1], Kevo[1] + else + C̃ = _transpose_front(R * _transpose_tail(V̂0[i])) # transport R through frozen V̂0[i] + ηh = max(ηh, norm(C̃)) + zc0 = zerovector!(similar(C̃, codomain(C̃) ← domain(Û0[i]))) + C⁰ = _pbug_stack_child(Û0[i], zc0) + Ĉ¹ = _pbug_stack_child(Kevo[i], C̃) + end + Ũ2, = _pbug_newdirs(C⁰, Ĉ¹, alg.alg_orth) + As[i] = catdomain(C⁰, Ũ2) + R = Ũ2' * Ĉ¹ # evolved amplitude in the new dirs + end + # root (Kusch 2024, Variant 2): the `2r` Galerkin `S̄1 = Kevo[L]` in the `Û0` rows, stacked with + # the transported coupling (the `Ũ2ᵀK` analogue) in the `Ũ2` rows; the "new–new" corner is ZERO + # (right bond trivial at the root ⇒ no `LᵀṼ2` block either), so the local error is O(dt³). + C̃L = _transpose_front(R * _transpose_tail(V̂0[L])) + ηh = max(ηh, norm(C̃L)) + As[L] = _pbug_stack_child(Kevo[L], C̃L) + + return FiniteMPS(As; overwrite = true, normalize = imaginary_evolution), ηh +end -# Assemble the augmented (pre-truncation) parallel-BUG state from a single frozen `t₀` snapshot, -# following Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4 specialized to the caterpillar tree -# rooted at site `L`. Two phases: +# Assemble the augmented (pre-truncation) FIRST-ORDER parallel-BUG state from a single frozen `t₀` +# snapshot, following Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4 specialized to the caterpillar +# tree rooted at site `L` (the second-order `ParallelBUG2` has its own `_pbug2_assemble_core`). +# Two phases: # # 1. Galerkin-evolve every local center `AC[i]` from the frozen snapshot (the K-steps of # Alg. 2/3, on the amplitude-weighted subtree objects `Y_τ⁰ = U_τ⁰S_τ⁰`). These `L` local @@ -320,7 +589,7 @@ end # tensor `[C̄_L(t₁); C̃_L]`, whose coupling row projects the full derivative of `ψ₀` onto the # new directions. The "new-new" corners are implicitly zero, which is what makes the integrator # first order. -function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) +function _pbug_assemble_core(ψ, H, t, dt, alg::AbstractParallelBUG; imaginary_evolution::Bool = false) L = length(ψ) ψ.AC[L] # gauge to the root ψ₀ = copy(ψ) @@ -446,7 +715,7 @@ _pbug_stack_child(top, bot) = # Cut the augmented bonds back down. A truncating `trscheme` selects rank-adaptivity; the default # `notrunc()` restores the pre-step virtual space of every bond (fixed-rank parallel BUG). # Environments self-heal lazily for the changed bonds. -function _pbug_truncate!(ϕ, alg::ParallelBUG, Vs; normalize::Bool = false) +function _pbug_truncate!(ϕ, alg::AbstractParallelBUG, Vs; normalize::Bool = false) if !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) changebonds!(ϕ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) else @@ -472,7 +741,7 @@ end # copying version, shared by both BUG integrators function timestep( ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, - alg::Union{BUG, ParallelBUG}, envs::AbstractMPSEnvironments...; + alg::Union{BUG, ParallelBUG, ParallelBUG2}, envs::AbstractMPSEnvironments...; imaginary_evolution::Bool = false, kwargs... ) isreal = (scalartype(ψ) <: Real && !imaginary_evolution) diff --git a/test/algorithms/parallelbug.jl b/test/algorithms/parallelbug.jl index b8e7668e3..35d14a21c 100644 --- a/test/algorithms/parallelbug.jl +++ b/test/algorithms/parallelbug.jl @@ -161,6 +161,35 @@ using Random @test all(s -> s > 0.8, slopes) end + # 7b. THE decisive local-order gate (Ceruti et al. 2024; MPSKit design §8): the single-step local + # error ‖step(dt) − exp(−iH·dt)ψ₀‖ against the dense propagator, log–log slope vs dt, at + # L = 3 and L = 4 (2-site is exact and does NOT exercise the dropped "new–new" corner, so + # L ≥ 3 is required). A first-order method has local error O(dt²) ⇒ slope ≈ 2; this is the gate + # that separates genuine second order (slope ≈ 3, see `ParallelBUG2`) from a better-constant + # first-order scheme. Full bonds (ℙ^8) isolate the time-discretization error. + @testset "local order slope L=$Lc" for Lc in (3, 4) + Random.seed!(2) + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) + ψf = normalize!(FiniteMPS(rand, ComplexF64, Lc, ℙ^2, ℙ^8)) + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψf); ψvec /= norm(ψvec) + dts = [0.04, 0.02, 0.01, 0.005] + errs = map(dts) do δt + ref = exp(-im * Hmat * δt) * ψvec + ϕ, = timestep(ψf, Hc, 0.0, δt, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-12))) + out = convert(TensorMap, ϕ) + return norm(out - ref) + end + slopes = [ + (log(errs[i + 1]) - log(errs[i])) / (log(dts[i + 1]) - log(dts[i])) + for i in 1:(length(dts) - 1) + ] + @info "ParallelBUG local order" Lc errs slopes + for s in slopes + @test s ≈ 2 atol = 0.3 + end + end + # 8. the truncation tolerance ϑ maps onto the accumulating `c·n·ϑ` error term: tightening ϑ # improves the overlap with a ϑ → 0 run of the same integrator (which shares the time # discretization error, so the comparison isolates the truncation term). @@ -279,3 +308,139 @@ end @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 end end + +# `ParallelBUG2` (second-order parallel BUG, Kusch 2024 Variant 2 / `4r`, EXPERIMENTAL). The genuine +# second-order assembly enriches every bond basis with one `H·ψ₀` application (`Û0`/`V̂0`), runs the +# `2r` Galerkin K-steps freezing the enriched right basis, and transports the EVOLVED-amplitude +# coupling to the root through the frozen right isometries, keeping the "new–new" corner zero (O(dt³)). +# It reproduces the exact two-site step, conserves energy / the eigenstate phase, agrees with `TDVP`, +# lowers the energy monotonically in imaginary time, preserves the charge sectors, and — the decisive +# gate — has a local error one order higher than the first-order `ParallelBUG`: slope ≥ 3 (vs ≈ 2). +@testset "ParallelBUG2 (second order)" verbose = true begin + dt = 0.1 + L = 6 + H = force_planar(heisenberg_XXX(Float64, Trivial; spin = 1 // 2, L)) + ψ = FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4) + ψ₀, = find_groundstate(ψ, H; verbosity = 0) + E₀ = expectation_value(ψ₀, H) + + # 1. two sites: exact reproduction of the dense exp(-iH·dt) step + @testset "two-site exactness vs dense reference" begin + Random.seed!(2) + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = 2)) + ψc = FiniteMPS(rand, ComplexF64, 2, ℙ^2, ℙ^4) + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψc); ψvec /= norm(ψvec) + ref = exp(-im * Hmat * 0.05) * ψvec + ψ1, = timestep(ψc, Hc, 0.0, 0.05, ParallelBUG2(; trscheme = truncerror(; atol = 1.0e-12))) + out = convert(TensorMap, ψ1); out /= norm(out) + @test 1 - abs(dot(out, ref)) < 1.0e-10 + end + + # 2. energy conservation + eigenstate phase (amplitude carried exactly once, at the root) + @testset "energy conservation + eigenstate phase" begin + ψ1, envs = timestep(ψ₀, H, 0.0, dt, ParallelBUG2(; trscheme = truncerror(; atol = 1.0e-12))) + E1 = expectation_value(ψ1, H, envs) + @test E₀ ≈ E1 atol = 1.0e-2 + @test dot(ψ1, ψ₀) ≈ exp(im * dt * E₀) atol = 1.0e-4 + end + + # 3. THE decisive local-order gate (mirror of the first-order testset above). The single-step + # local error ‖step(dt) − exp(−iH·dt)ψ₀‖ has slope ≈ 2 for first order and slope ≈ 3 for genuine + # second order (local `O(dt³)`). Full bonds (ℙ^8) isolate the time-discretization error. + @testset "local order slope ≈ 3 (full, L=$Lc)" for Lc in (4, 5) + Random.seed!(2) + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) + ψf = normalize!(FiniteMPS(rand, ComplexF64, Lc, ℙ^2, ℙ^8)) + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψf); ψvec /= norm(ψvec) + dts = [0.04, 0.02, 0.01, 0.005] + errs = map(dts) do δt + ref = exp(-im * Hmat * δt) * ψvec + ϕ, = timestep(ψf, Hc, 0.0, δt, ParallelBUG2(; trscheme = truncerror(; atol = 1.0e-12))) + return norm(convert(TensorMap, ϕ) - ref) + end + slopes = [ + (log(errs[i + 1]) - log(errs[i])) / (log(dts[i + 1]) - log(dts[i])) + for i in 1:(length(dts) - 1) + ] + @info "ParallelBUG2 local order (full)" Lc errs slopes + for s in slopes + @test s ≈ 3 atol = 0.4 # genuine second order (vs the first-order value 2) + end + end + + # the second-order slope must persist as the chain grows past a single interior bond; L = 6 with + # a reduced bond dimension ℙ^4 has an unsaturated interior bond (a genuine dropped corner). + @testset "local order slope ≈ 3 (reduced bond, L=6)" begin + Random.seed!(2) + Lc = 6 + Hc = force_planar(transverse_field_ising(ComplexF64, Trivial; L = Lc)) + ψf = normalize!(FiniteMPS(rand, ComplexF64, Lc, ℙ^2, ℙ^4)) + Hmat = convert(TensorMap, Hc) + ψvec = convert(TensorMap, ψf); ψvec /= norm(ψvec) + dts = [0.04, 0.02, 0.01, 0.005] + errs = map(dts) do δt + ref = exp(-im * Hmat * δt) * ψvec + ϕ, = timestep(ψf, Hc, 0.0, δt, ParallelBUG2(; trscheme = truncerror(; atol = 1.0e-12))) + return norm(convert(TensorMap, ϕ) - ref) + end + slopes = [ + (log(errs[i + 1]) - log(errs[i])) / (log(dts[i + 1]) - log(dts[i])) + for i in 1:(length(dts) - 1) + ] + @info "ParallelBUG2 local order (reduced)" errs slopes + for s in slopes + @test s ≈ 3 atol = 0.4 + end + end + + # 4. agreement with TDVP over a few short real-time steps of a random MPS + @testset "agreement with TDVP" begin + Random.seed!(1234) + ψr = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4)) + δt = 0.01 + ψ_p, ψ_tdvp = ψr, ψr + for k in 0:4 + ψ_p, = timestep(ψ_p, H, k * δt, δt, ParallelBUG2(; trscheme = truncerror(; atol = 1.0e-12))) + ψ_tdvp, = timestep(ψ_tdvp, H, k * δt, δt, TDVP()) + end + @test expectation_value(ψ_p, H) ≈ expectation_value(ψ_tdvp, H) atol = 1.0e-3 + @test abs(dot(ψ_p, ψ_tdvp)) ≈ 1 atol = 1.0e-3 + end + + # 5. imaginary-time evolution lowers the energy monotonically and stays normalized + @testset "imaginary-time lowers energy" begin + Random.seed!(5) + ψi = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^4)) + E_start = real(expectation_value(ψi, H)) + E_prev = E_start + for _ in 1:20 + ψi, = timestep( + ψi, H, 0.0, 0.1, ParallelBUG2(; trscheme = truncerror(; atol = 1.0e-10)); + imaginary_evolution = true + ) + E_now = real(expectation_value(ψi, H)) + @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy + E_prev = E_now + end + @test E_prev < E_start - 1.0 # substantial lowering toward the ground state + @test norm(ψi) ≈ 1 atol = 1.0e-6 # imaginary-time renormalizes each step + end + + # 6. charge-sector coverage: a fixed-rank (notrunc) Z2 step preserves the total boundary charge + @testset "Z2 symmetry preservation" begin + Random.seed!(161803) + Hz = transverse_field_ising(ComplexF64, Z2Irrep; g = 1.0, L) + ψz = FiniteMPS(physicalspace(Hz), Z2Space(0 => 4, 1 => 4)) + ψz0, = find_groundstate(ψz, Hz; verbosity = 0) + Ez = expectation_value(ψz0, Hz) + Rtot = right_virtualspace(ψz0, L) + + ψ1, envs = timestep(ψz0, Hz, 0.0, dt, ParallelBUG2()) # fixed-rank (notrunc) + E1 = expectation_value(ψ1, Hz, envs) + @test Ez ≈ E1 atol = 1.0e-2 + @test dot(ψ1, ψz0) ≈ exp(im * dt * Ez) atol = 1.0e-4 + @test right_virtualspace(ψ1, L) == Rtot # total boundary charge preserved + end +end From 184445d6a8b66219858eaf66799023272c23db93 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Mon, 13 Jul 2026 09:14:59 -0400 Subject: [PATCH 19/23] BUG: simplify the integrator implementations Bring src/algorithms/timestep/bug.jl in line with the tdvp.jl house style without changing any behavior: trim the large in-code comment blocks and the ParallelBUG/ParallelBUG2 docstring essays to short comments, and remove the one-use helper proliferation. - drop the `_pbug_assemble` forwarding stubs; the assembly cores are now the dispatch targets `_pbug_assemble(::ParallelBUG)` / `_pbug_assemble(::ParallelBUG2)` - collapse the two `_pbug_with_rejections` methods into one generic `typeof(alg)` rebuild - inline `_pbug_overwrite!`, `_pbug2_embed`, and `_pbug2_newdirs_right` into their sole call sites Genuinely reused helpers are kept: `_bug_augment_left/right` (unit-tested), `_pbug_newdirs`/`_pbug_stack_child`/`_pbug_truncate!`, the LazySum dispatch families, and the `_pbug2_left/right_enrich` subroutines. All BUG, ParallelBUG, and ParallelBUG2 test suites pass unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/algorithms/timestep/bug.jl | 372 +++++++++++---------------------- 1 file changed, 123 insertions(+), 249 deletions(-) diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index f01f87c91..947b039d1 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -4,22 +4,19 @@ $(TYPEDEF) Single site MPS time-evolution algorithm based on the Basis-Update & Galerkin (BUG) integrator, an unconventional robust integrator for dynamical low-rank approximation. -Unlike [`TDVP`](@ref), BUG advances both the basis-carrying (K-step) and the core (Galerkin C-step) -tensors *forward* in time. In particular there is no backward-in-time substep, which makes it a -natural choice for imaginary-time / dissipative evolution where the backward core step of TDVP -integrators can become unstable. - -Passing a truncating `trscheme` (anything other than the default `notrunc()`) switches on -**rank-adaptivity**: each half-sweep augments every bond with the new directions discovered by the -evolved connecting tensor (keeping the old basis as the leading block, `[U₀ │ K₁]`) and then -truncates the enlarged bonds back down to the tolerance of `trscheme` by an truncation sweep. -The bond dimension therefore grows and shrinks automatically to track the entanglement of the -evolving state. The default `notrunc()` recovers the fixed-rank integrator exactly. +Unlike [`TDVP`](@ref), BUG advances both the basis (K-step) and the core (Galerkin C-step) tensors +*forward* in time, with no backward-in-time substep. This makes it a natural choice for +imaginary-time / dissipative evolution, where the backward core step of TDVP can become unstable. + +Passing a truncating `trscheme` (anything other than `notrunc()`) switches on **rank-adaptivity**: +each half-sweep augments every bond with the new directions discovered by the evolved connecting +tensor (old basis first, `[U₀ │ K₁]`) and then truncates back down to the tolerance of `trscheme`. +The bond dimension grows and shrinks automatically to track the entanglement; `notrunc()` recovers +the fixed-rank integrator. !!! note - Real-time evolution does not normalize the resulting state: neither the augmentation nor - the truncation normalizes, so the state norm reflects the accumulated truncation error. - Imaginary-time evolution renormalizes after every half-sweep, similar to a ground-state search. + Real-time evolution does not normalize the resulting state, so the state norm reflects the + accumulated truncation error. Imaginary-time evolution renormalizes after every half-sweep. ## Fields @@ -65,17 +62,13 @@ function timestep!( envs::AbstractMPSEnvironments = environments(ψ, H, ψ); imaginary_evolution::Bool = false ) - # symmetric 2nd-order: a dt/2 left→right half-sweep composed with its dt/2 mirror. Each - # half-sweep reprojects the frozen old connecting tensor onto the already-updated bases, evolves - # it forward, and installs the new basis; a truncating `trscheme` (rank-adaptive BUG) then cuts - # the augmented state back down with an optimal SVD sweep. Fixed-rank keeps the evolved isometry, - # rank-adaptive augments it old-first (`[U₀ │ K₁]`, deferring the cut to `changebonds!`). + # symmetric 2nd-order: a dt/2 left→right half-sweep composed with its dt/2 mirror L = length(ψ) h = dt / 2 truncates = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) svdcut = SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd) - # sweep left to right (root = last site) + # left→right half-sweep (root = last site) ψ.AC[1] # gauge center to site 1 ψ_old = copy(ψ) # frozen bases / reprojection inputs T = isomorphism(scalartype(ψ), left_virtualspace(ψ_old, 1) ← left_virtualspace(ψ_old, 1)) @@ -84,7 +77,7 @@ function timestep!( AC = integrate(AC_hamiltonian(i, ψ, H, ψ, envs), Ĉ, t, h, alg.integrator; imaginary_evolution) U₀ = _mul_front(T, ψ_old.AL[i]) # old left isometry in the new frame if truncates - U = _bug_augment_left(U₀, AC, alg.alg_orth) + U = _bug_augment_left(U₀, AC, alg.alg_orth) # old-first augment; cut deferred to changebonds! C = U' * AC else U, C = left_gauge(AC, alg.alg_orth) @@ -100,7 +93,7 @@ function timestep!( ψ.AC[L] = AC truncates && changebonds!(ψ, svdcut; normalize = imaginary_evolution) - # sweep right to left (root = first site), the mirror + # right→left half-sweep (root = first site), the mirror ψ.AC[L] # gauge center to site L ψ_old = copy(ψ) T = isomorphism(scalartype(ψ), right_virtualspace(ψ_old, L) ← right_virtualspace(ψ_old, L)) @@ -128,31 +121,26 @@ function timestep!( return ψ, envs end -# Augment the RIGHT bond for the left→right sweep: orthonormalize the stacked `[U₀ │ K₁]` (the old -# left-isometry `U₀` first, then the evolved candidate `K₁`) with a single QR. This keeps `U₀` as -# the leading per-sector block and appends only the directions of `K₁` orthogonal to it, while the -# QR caps each charge sector at `dim(Vl⊗P, c)` so no direction outside `Vl⊗P` is ever added. +# augment the RIGHT bond (left→right sweep): orthonormalize the stacked `[U₀ │ K₁]` (old isometry +# first) so `U₀` stays the leading per-sector block and only the new directions of `K₁` are appended. function _bug_augment_left(U₀, K₁, alg_orth = Defaults.alg_orth()) - Û, _ = left_orth(catdomain(U₀, K₁); alg = alg_orth) # Vl⊗P ← (Vr₀ ⊕ Vr_new), old-first + Û, _ = left_orth(catdomain(U₀, K₁); alg = alg_orth) return Û end -# Mirror of `_bug_augment_left` for the right→left sweep, on the `_transpose_tail` form -# (`Vl ← P⊗Vr`, in which a right-isometry has orthonormal rows): a single LQ orthonormalizes the -# stacked rows `[U₀; K₁]`, keeping `U₀` as the leading block. +# mirror of `_bug_augment_left` for the right→left sweep, on the `_transpose_tail` form (right-isometry +# with orthonormal rows): an LQ orthonormalizes the stacked rows `[U₀; K₁]`, keeping `U₀` leading. function _bug_augment_right(U₀, K₁, alg_orth = Defaults.alg_orth()) - stacked = catcodomain(_transpose_tail(U₀), _transpose_tail(K₁)) # (Vl₀ ⊕ Vl_K) ← P⊗Vr - _, Û = right_orth(stacked; alg = alg_orth) # V̂ ← P⊗Vr, old-first + stacked = catcodomain(_transpose_tail(U₀), _transpose_tail(K₁)) + _, Û = right_orth(stacked; alg = alg_orth) return _transpose_front(Û) end # Parallel BUG # ------------ -# Shared supertype for the parallel-BUG integrators (`ParallelBUG` first order, `ParallelBUG2` -# second order). The assembly/truncation/rejection helpers below dispatch on it, and the -# second-order assembly reuses the first-order core (`_pbug_assemble_core`) on an `H·ψ₀`-enriched -# snapshot, so almost all machinery is shared. +# Shared supertype for the parallel-BUG integrators (`ParallelBUG` first order, `ParallelBUG2` second +# order); the assembly / truncation / rejection helpers below dispatch on it. abstract type AbstractParallelBUG <: Algorithm end """ @@ -161,47 +149,24 @@ $(TYPEDEF) Single site MPS time-evolution algorithm based on the *parallel* Basis-Update & Galerkin (BUG) integrator for tree tensor networks, specialized to the linear (`FiniteMPS`) tree. -Unlike the sequential [`BUG`](@ref), every local problem is solved from the **same frozen `t₀` -snapshot** (`ψ₀ = copy(ψ)` and its environments): there is no sweep and no sequential dependency -between the local integrations, so they are mutually independent and parallelizable. Rooting the -caterpillar at the last site, every local center `AC[i]` is evolved forward from `t₀`. A cheap -leaves→root assembly pass (the augmentation `A_τ` of Ceruti et al. 2024, Alg. 4) then augments -every bond with the new directions discovered by the frozen evolutions — old basis first, -`[U₀ │ Ũ₁]` — where the new directions at each bond are orthonormalized from the evolved center -*stacked with a first-order coupling block* on the new rows of the previous bond, so that -directions discovered deep in the chain propagate up toward the root. The interior tensors of the -augmented state are isometries (the amplitude and phase of the evolved centers is discarded with -the orthonormalization); the root tensor is the evolved center `AC[L]` augmented with the -projection of the full time derivative onto the new directions, so the amplitude and all -first-order updates enter exactly once, at the root. Finally an SVD sweep truncates the (at most -doubled) bonds back down. -Like [`BUG`](@ref), it advances every tensor *forward* in time (no backward substep), which suits -imaginary-time / dissipative evolution. - -The integrator is **first-order** in time (the discarded "new–new" coupling corners are `O(dt²)`), -in contrast to the second-order symmetrized sequential [`BUG`](@ref). The truncation tolerance of -`trscheme` maps to the BUG tolerance `ϑ`; because the global error accumulates as `c·n·ϑ` over `n` -steps, scale `ϑ` with `dt` for a fixed target accuracy. Any truncating `trscheme` (e.g. -`truncerror`) makes the bond dimension grow and shrink to track the entanglement of the evolving -state; the default `notrunc()` restores the pre-step virtual spaces after every step (fixed-rank -parallel BUG). - -Setting `maxiter_rejection > 0` enables **step rejection**: when a truncating step keeps the *full* -augmented (doubled) space on some bond — a single doubling was not enough to resolve `trscheme` — -the step is recomputed as two half-steps, so a smaller per-step normal component `η` fits within one -doubling per sub-step (cf. `rejection_check` of Ceruti et al. 2024). The local error estimator -`η = ‖C̃‖/dt` (the frozen derivative projected onto the new directions, their eq. 6) is logged at -`@debug` level each step. +Unlike the sequential [`BUG`](@ref), every local center `AC[i]` is evolved forward from the **same +frozen `t₀` snapshot**: there is no sweep and no sequential dependency, so the local integrations are +mutually independent and parallelizable. A cheap leaves→root pass then augments every bond with the +new directions the frozen evolutions discovered (old basis first, `[U₀ │ Ũ₁]`), and a final SVD sweep +truncates the (at most doubled) bonds back down. Like [`BUG`](@ref) it advances every tensor *forward* +in time (no backward substep), which suits imaginary-time / dissipative evolution. + +The integrator is **first-order** in time. Any truncating `trscheme` makes the bond dimension grow +and shrink to track the entanglement; the default `notrunc()` restores the pre-step virtual spaces +(fixed-rank parallel BUG). Setting `maxiter_rejection > 0` enables **step rejection**: a step that +saturates the doubling on some bond is recomputed as two half-steps (cf. Ceruti et al. 2024). !!! warning "Experimental" - This integrator is **work in progress**. The API and behaviour may change. The step-rejection - trigger is the rank-saturation criterion; the `η`-threshold trigger `dt·η > c·ϑ` and the - second-order augmented-Galerkin variant of Ceruti et al. (2024) are not yet implemented. + This integrator is **work in progress**; the API and behaviour may change. !!! note - Real-time evolution does not normalize the resulting state: neither the augmentation nor the - truncation normalizes, so the state norm reflects the accumulated truncation error. - Imaginary-time evolution renormalizes after every step, similar to a ground-state search. + Real-time evolution does not normalize the resulting state, so the state norm reflects the + accumulated truncation error. Imaginary-time evolution renormalizes after every step. ## Fields @@ -259,41 +224,27 @@ end $(TYPEDEF) Single site MPS time-evolution algorithm based on the *second-order* variant of the *parallel* -Basis-Update & Galerkin (BUG) integrator for tree tensor networks (Kusch 2024), specialized to the -linear (`FiniteMPS`) tree. It is to [`ParallelBUG`](@ref) what [`TDVP2`](@ref) is to [`TDVP`](@ref): -a separate, more accurate integrator sharing the same interface. - -The genuine second order comes from **pre-augmenting every bond basis to rank `2r` with one -`H·ψ₀` application *before* evolving** (Kusch 2024, "Variant 2" / `4r`). Concretely each left/right -bond isometry is enlarged to `Û₀ = orth([AL⁰ │ (H·ψ₀)])` / `V̂₀ = orth([AR⁰ │ (H·ψ₀)])` (the -directions opened by a single effective-Hamiltonian application to the frozen center), the rank-`2r` -Galerkin K-steps are evolved on those enriched environments *freezing the enriched right basis* -`V̂₀`, and the bonds are augmented to at most `4r`. The `O(dt²)` content that the first-order scheme -discards is transported to the root as the **evolved-amplitude** coupling `R = Ũ₂ᵀĈ` (propagated -through the frozen right isometries). The root core is then assembled exactly as in the matrix -recipe — the rank-`2r` Galerkin `S̄₁` in the `Û₀` rows, the transported coupling (the `Ũ₂ᵀK` block) -in the `Ũ₂` rows, and the "new–new" corner kept **zero** — so the discarded content is `O(dt³)` -rather than the first-order `O(dt²)`, which lifts the local order from `2` to `3`. The interior -tensors stay isometries. A final SVD sweep truncates the (at most quadrupled) bonds back down to -`trscheme`. - -Everything else matches [`ParallelBUG`](@ref): the local problems are solved from a single frozen -`t₀` snapshot (mutually independent, parallelizable), the amplitude and phase enter exactly once at -the root, `notrunc()` gives the fixed-rank integrator, and it advances every tensor *forward* in -time (no backward substep), which suits imaginary-time / dissipative evolution. +Basis-Update & Galerkin (BUG) integrator for tree tensor networks (Kusch 2024, "Variant 2"), +specialized to the linear (`FiniteMPS`) tree. It is to [`ParallelBUG`](@ref) what [`TDVP2`](@ref) is +to [`TDVP`](@ref): a separate, more accurate integrator sharing the same interface. + +The genuine second order comes from **pre-augmenting every bond basis with one `H·ψ₀` application +before evolving**: each bond isometry is enlarged to rank `2r` with the directions opened by a single +effective-Hamiltonian application to the frozen center, the Galerkin K-steps are evolved on those +enriched environments, and the `O(dt²)` content the first-order scheme discards is transported to the +root, keeping the "new–new" corner zero (so the local error is `O(dt³)` rather than `O(dt²)`). +Everything else matches [`ParallelBUG`](@ref): one frozen `t₀` snapshot, mutually independent local +solves, amplitude carried once at the root, `notrunc()` for the fixed-rank variant, and forward-only +evolution (no backward substep). !!! warning "Experimental" - This integrator is **work in progress**. The API and behaviour may change. Its local error is - one order higher than the first-order [`ParallelBUG`](@ref): the single-step error scales as - `O(dt³)` (log–log slope `≈ 3`) versus `O(dt²)` (slope `≈ 2`). Only plain Hamiltonians are - supported so far (no `LazySum`/time-dependent operators). See - `research/PARALLEL_BUG_second_order_status.md` for the construction and its relation to the - reference tree-tensor-network algorithm. + This integrator is **work in progress**; the API and behaviour may change. Its single-step error + scales as `O(dt³)` (log–log slope `≈ 3`) versus the first-order `O(dt²)`. Only plain Hamiltonians + are supported (no `LazySum` / time-dependent operators). !!! note - Real-time evolution does not normalize the resulting state: neither the augmentation nor the - truncation normalizes, so the state norm reflects the accumulated truncation error. - Imaginary-time evolution renormalizes after every step, similar to a ground-state search. + Real-time evolution does not normalize the resulting state, so the state norm reflects the + accumulated truncation error. Imaginary-time evolution renormalizes after every step. ## Fields @@ -363,18 +314,14 @@ function timestep!( end truncates = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) - # remember the pre-step virtual spaces for the fixed-rank (`notrunc`) restore - Vs = [right_virtualspace(ψ, b) for b in 1:(L - 1)] + Vs = [right_virtualspace(ψ, b) for b in 1:(L - 1)] # pre-step spaces, for the fixed-rank restore ϕ, ηh = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) augVs = [right_virtualspace(ϕ, b) for b in 1:(L - 1)] # the (doubled) augmented spaces _pbug_truncate!(ϕ, alg, Vs; normalize = imaginary_evolution) - # step rejection (opt-in via `maxiter_rejection > 0`): if the truncation kept the *full* augmented - # space on some bond (a single doubling was not enough to resolve `trscheme`), the step is - # under-resolved (Ceruti et al. 2024; cf. `rejection_check.m`). Recompute it as two half-steps — - # sub-stepping lowers the per-step normal component `η = ηh/dt`, so one doubling per sub-step - # suffices. Bounded by `maxiter_rejection`, after which the saturated step is accepted. + # step rejection (opt-in): a bond that kept its full augmented space was under-resolved by a + # single doubling; recompute as two half-steps so one doubling per sub-step suffices. if truncates && alg.maxiter_rejection > 0 saturated = any(1:(L - 1)) do b return right_virtualspace(ϕ, b) == augVs[b] && augVs[b] != Vs[b] @@ -388,52 +335,34 @@ function timestep!( end end - # mutate `ψ` in place to become the assembled state (the generic loop reuses the object) - _pbug_overwrite!(ψ, ϕ) + # overwrite `ψ` in place with the assembled state (adopt `ϕ`'s tensors; identity-keyed envs self-heal) + for f in (:ALs, :ARs, :ACs, :Cs) + copyto!(getfield(ψ, f), getfield(ϕ, f)) + end return ψ, environments(ψ, H, ψ) end # rebuild a parallel-BUG algorithm with a reduced rejection budget (the structs are immutable) -function _pbug_with_rejections(alg::ParallelBUG, n::Int) - return ParallelBUG( - alg.integrator, alg.tolgauge, alg.gaugemaxiter, alg.alg_orth, - alg.trscheme, alg.alg_svd, alg.c, n, alg.finalize - ) -end -function _pbug_with_rejections(alg::ParallelBUG2, n::Int) - return ParallelBUG2( +function _pbug_with_rejections(alg::AbstractParallelBUG, n::Int) + return typeof(alg)( alg.integrator, alg.tolgauge, alg.gaugemaxiter, alg.alg_orth, alg.trscheme, alg.alg_svd, alg.c, n, alg.finalize ) end -# Dispatch the assembly on the integrator order. First order assembles directly from the frozen -# snapshot; second order first pre-augments every bond basis to rank `2r` with one `H·ψ₀` -# application, then runs the SAME first-order assembly core on that enriched snapshot — which turns -# the rank-`r` Galerkin into the rank-`2r` Galerkin of Kusch (2024, Variant 2), lifting the local -# order from `2` to `3` while keeping the "new–new" corner zero. -function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) - return _pbug_assemble_core(ψ, H, t, dt, alg; imaginary_evolution) -end -function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bool = false) - return _pbug2_assemble_core(ψ, H, t, dt, alg; imaginary_evolution) -end - -# ---- second-order (Variant 2) building blocks -------------------------------------------------- +# ---- second-order (Variant 2) assembly --------------------------------------------------------- -# old-first LEFT enrichment `Û0[1..L-1]` (rank `2r`) plus the enriched left-environment chain -# `GLhat[1..L]` (`⟨Û0|H|Û0⟩`). This is the frozen `H·ψ₀` pre-augmentation of Kusch (2024, §3.1): at -# bond `i` the old isometry `AL⁰[i]` is enlarged with the range of the frozen derivative image -# `W[i] = (H·ψ₀)ᵢ`, stacked leaves→root with the same mixed `⟨new|H|old⟩` coupling as the first-order -# assembly (span only ⇒ unscaled) so directions opened deep in the chain reach the root. `GLhat` is -# folded by explicit transfer matrices (NO `FiniteMPS` round-trip ⇒ the zero-weight enriched -# directions do not collapse under canonicalization). +# old-first LEFT enrichment `Û0[1..L-1]` (rank `2r`) plus the enriched left-environment chain `GLhat` +# (`⟨Û0|H|Û0⟩`): enlarge each `AL⁰[i]` with the range of the frozen derivative image `W[i]=(H·ψ₀)ᵢ`, +# stacked leaves→root with the mixed `⟨new|H|old⟩` coupling so directions opened deep in the chain +# reach the root. Envs are folded by explicit transfer (no `FiniteMPS` round-trip, so the zero-weight +# enriched directions do not collapse under canonicalization). function _pbug2_left_enrich(ψ₀, H, envs₀, W, alg_orth) L = length(ψ₀) Û0 = Vector{typeof(ψ₀.AL[1])}(undef, L - 1) GLhat = Vector{Any}(undef, L) GLhat[1] = leftenv(envs₀, 1, ψ₀) - GLmix = leftenv(envs₀, 1, ψ₀) # mixed ⟨new|H|old⟩ chain (span only, unscaled) + GLmix = leftenv(envs₀, 1, ψ₀) # mixed ⟨new|H|old⟩ chain local GLnew for i in 1:(L - 1) if i == 1 @@ -453,9 +382,9 @@ function _pbug2_left_enrich(ψ₀, H, envs₀, W, alg_orth) return Û0, GLhat end -# old-first RIGHT enrichment `V̂0[2..L]` (rank `2r`) plus the enriched right-environment chain -# `GRhat[1..L]` (`⟨V̂0|H|V̂0⟩`), the mirror of `_pbug2_left_enrich`. The interior K-step freezes this -# enriched right basis (freezing the *old* right basis instead only yields local slope 2). +# old-first RIGHT enrichment `V̂0[2..L]` (rank `2r`) plus the enriched right-environment chain `GRhat`, +# the mirror of `_pbug2_left_enrich`. The interior K-step freezes this enriched right basis (freezing +# the old right basis instead only yields local slope 2). function _pbug2_right_enrich(ψ₀, H, envs₀, W, alg_orth) L = length(ψ₀) V̂0 = Vector{typeof(ψ₀.AR[1])}(undef, L) @@ -471,7 +400,10 @@ function _pbug2_right_enrich(ψ₀, H, envs₀, W, alg_orth) C⁰ = catdomain(ψ₀.AR[i], zerovector!(similar(C̃))) Ĉ = catdomain(W[i], C̃) end - V̂, = _pbug2_newdirs_right(C⁰, Ĉ, alg_orth) + # new left-bond directions `Ĉ` opens beyond the right-isometry `C⁰` (in `_transpose_tail` form) + N = right_null!(_transpose_tail(C⁰; copy = true)) + _, Q = right_orth(_transpose_tail(Ĉ) * N'; alg = alg_orth) + V̂ = Q * N V̂0[i] = _transpose_front(catcodomain(_transpose_tail(C⁰), V̂)) GRhat[i - 1] = TransferMatrix(V̂0[i], H[i], V̂0[i]) * GRhat[i] GRnew = TransferMatrix(ψ₀.AR[i], H[i], _transpose_front(V̂)) * GRmix # ket=old, bra=new @@ -481,37 +413,13 @@ function _pbug2_right_enrich(ψ₀, H, envs₀, W, alg_orth) return V̂0, GRhat end -# right-side analogue of `_pbug_newdirs`: new LEFT-bond (codomain) directions the candidate `cand` -# opens beyond the right-isometry `AR`, returned in the `_transpose_tail` form `r_new ← (P⊗Vr)`. -function _pbug2_newdirs_right(AR, cand, alg_orth = Defaults.alg_orth()) - N = right_null!(_transpose_tail(AR; copy = true)) # Vcomp ← (P⊗Vr) - g = _transpose_tail(cand) * N' # Vl_cand ← Vcomp - _, Q = right_orth(g; alg = alg_orth) # Q: r_new ← Vcomp - return Q * N, nothing # r_new ← (P⊗Vr) -end - -# embed the old center `AC` into the enriched `(VlL ⊗ P) ← Vr` bond spaces with zero weight in the -# new directions (`absorb!` into a larger-space zero tensor handles the old-first block + duals). -function _pbug2_embed(AC, VlL, Vp, Vr) - return absorb!(zerovector!(similar(AC, (VlL ⊗ Vp) ← Vr)), AC) -end - -# Genuine second-order parallel-BUG assembly (Kusch 2024, Variant 2 / `4r`), specialized to the -# caterpillar rooted at site `L`. One frozen `t₀` snapshot drives everything: -# -# 1. Pre-augment the left/right bond bases to rank `2r` with one `H·ψ₀` application (`Û0`, `V̂0`) and -# fold the enriched environments `GLhat`/`GRhat` by explicit transfer (Kusch's O(h²)-capturing -# predictor bases; the `⟨…⟩` collapse of a `FiniteMPS` round-trip is thereby avoided). -# 2. K-step every center on the enriched environments, freezing the enriched right basis `V̂0` -# (mutually independent ⇒ threaded like the first-order phase-1 solves) → `Kevo`. -# 3. A leaves→root sweep builds the interior `4r` isometries `[Û0 | Ũ2]` (new directions from `Kevo`) -# and transports the EVOLVED-amplitude coupling `R = Ũ2ᵀĈ` one site at a time through the frozen -# right isometry `V̂0` — this is what carries the genuine O(h²) content between interior bonds -# (the first-order frozen-derivative coupling is ≈0 against the enriched basis, giving only slope 2). -# 4. The root core is a Galerkin step on the assembled `4r` left basis, which now spans the enriched, -# K-evolved and transported-coupling directions; the "new–new" corner stays implicitly zero (O(h³)). -# The final tensors carry genuine weight, so a `FiniteMPS` is materialized only here (no collapse). -function _pbug2_assemble_core(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bool = false) +# Genuine second-order parallel-BUG assembly (Kusch 2024, Variant 2 / `4r`) rooted at site `L`, from a +# single frozen `t₀` snapshot: (1) pre-augment the bond bases to rank `2r` with one `H·ψ₀` application +# (`Û0`/`V̂0`) and fold the enriched envs by explicit transfer; (2) K-step every center on the enriched +# envs, freezing `V̂0`; (3) a leaves→root sweep builds the `4r` isometries `[Û0 | Ũ2]` and transports +# the evolved-amplitude coupling `R = Ũ2ᵀĈ` through the frozen `V̂0`; (4) the root stacks the `2r` +# Galerkin `Kevo[L]` with the transported coupling, keeping the "new–new" corner zero (local `O(dt³)`). +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bool = false) L = length(ψ) ψ.AC[L] # gauge to the root ψ₀ = copy(ψ) @@ -532,7 +440,10 @@ function _pbug2_assemble_core(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evoluti enrL(i) = i == 1 ? left_virtualspace(ψ₀, 1) : (i <= L - 1 ? space(Û0[i], 1) : only(domain(Û0[L - 1]))) enrR(i) = i == L ? right_virtualspace(ψ₀, L) : space(V̂0[i + 1], 1) - embed(i) = _pbug2_embed(ψ₀.AC[i], enrL(i), physicalspace(ψ₀, i), enrR(i)) + # embed the old center into the enriched bond spaces with zero weight in the new directions + embed(i) = absorb!( + zerovector!(similar(ψ₀.AC[i], (enrL(i) ⊗ physicalspace(ψ₀, i)) ← enrR(i))), ψ₀.AC[i] + ) # K/S steps on the enriched environments (freeze `V̂0`); independent ⇒ threaded Kevo = Vector{typeof(ψ₀.AC[1])}(undef, L) @@ -561,9 +472,8 @@ function _pbug2_assemble_core(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evoluti As[i] = catdomain(C⁰, Ũ2) R = Ũ2' * Ĉ¹ # evolved amplitude in the new dirs end - # root (Kusch 2024, Variant 2): the `2r` Galerkin `S̄1 = Kevo[L]` in the `Û0` rows, stacked with - # the transported coupling (the `Ũ2ᵀK` analogue) in the `Ũ2` rows; the "new–new" corner is ZERO - # (right bond trivial at the root ⇒ no `LᵀṼ2` block either), so the local error is O(dt³). + # root: `2r` Galerkin `Kevo[L]` (Û0 rows) stacked with the transported coupling (Ũ2 rows); the + # "new–new" corner stays zero (right bond trivial at the root), so the local error is `O(dt³)`. C̃L = _transpose_front(R * _transpose_tail(V̂0[L])) ηh = max(ηh, norm(C̃L)) As[L] = _pbug_stack_child(Kevo[L], C̃L) @@ -571,41 +481,23 @@ function _pbug2_assemble_core(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evoluti return FiniteMPS(As; overwrite = true, normalize = imaginary_evolution), ηh end -# Assemble the augmented (pre-truncation) FIRST-ORDER parallel-BUG state from a single frozen `t₀` -# snapshot, following Ceruti et al. 2024 (arXiv:2412.00858) Alg. 1-4 specialized to the caterpillar -# tree rooted at site `L` (the second-order `ParallelBUG2` has its own `_pbug2_assemble_core`). -# Two phases: -# -# 1. Galerkin-evolve every local center `AC[i]` from the frozen snapshot (the K-steps of -# Alg. 2/3, on the amplitude-weighted subtree objects `Y_τ⁰ = U_τ⁰S_τ⁰`). These `L` local -# solves are mutually independent, which is the parallel-in-time structure of the integrator. -# 2. A leaves→root augmentation pass (Alg. 4): at bond `i` the new directions `Ũᵢ` are -# orthonormalized against the (zero-padded) old isometry from the evolved center *stacked with -# the first-order coupling block* `C̃ᵢ = dt′·⟨Ũᵢ₋₁|H|ψ₀⟩-derivative of AC[i]` on the new rows of -# the previous bond — this reconciliation is what lets directions discovered deep in the chain -# propagate to the root. Only the range of these stacks is kept (their amplitude and phase are -# discarded with the R-factor), so the interior site tensors of the augmented state are the -# isometries `[old │ Ũᵢ]`, and all first-order (and amplitude) content is carried by the root -# tensor `[C̄_L(t₁); C̃_L]`, whose coupling row projects the full derivative of `ψ₀` onto the -# new directions. The "new-new" corners are implicitly zero, which is what makes the integrator -# first order. -function _pbug_assemble_core(ψ, H, t, dt, alg::AbstractParallelBUG; imaginary_evolution::Bool = false) +# First-order parallel-BUG assembly (Ceruti et al. 2024, arXiv:2412.00858, Alg. 1-4) rooted at site +# `L`. Phase 1: Galerkin-evolve every center `AC[i]` from the frozen snapshot (independent ⇒ parallel). +# Phase 2: a leaves→root sweep orthonormalizes each evolved center *stacked with a first-order coupling +# block* `C̃ᵢ` on the previous bond's new rows (`[old │ Ũᵢ]`), propagating deep new directions to the +# root. Interior tensors are pure isometries (amplitude/phase discarded); all first-order + amplitude +# content enters once, at the root `[C̄_L; C̃_L]`. The zero "new–new" corners make it first order. +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) L = length(ψ) ψ.AC[L] # gauge to the root ψ₀ = copy(ψ) envs₀ = environments(ψ₀, H, ψ₀) dt′ = imaginary_evolution ? -dt : -im * dt - # Pre-populate the frozen environments serially: `leftenv`/`rightenv` on `FiniteEnvironments` - # lazily *mutate* their cache (finite_envs.jl), so the parallel phase-1 solves below must only - # ever read them. After this pass every `AC_hamiltonian(i)` reads a warm, read-only cache. + # warm the lazily-cached envs serially so the threaded phase-1 solves below only read them _pbug_warmup_envs!(envs₀, L, ψ₀) - # phase 1: frozen-snapshot Galerkin evolutions (independent local solves). The interior - # K-steps evolve the amplitude-weighted centers `AC[i]` (the paper's `Y_τ⁰ = U_τ⁰S_τ⁰`); only - # their range is kept, so the amplitude they carry is discarded in the orthonormalization. All - # `L` solves read the read-only snapshot and are mutually independent — the parallel-in-time - # structure; `tmap!` threads them according to `Defaults.scheduler[]` (as in `tdvp.jl`). + # phase 1: frozen-snapshot Galerkin evolutions (independent local solves, threaded) scheduler = Defaults.scheduler[] Cevo = Vector{typeof(ψ₀.AC[1])}(undef, L) tmap!(Cevo, 1:L; scheduler) do i @@ -616,21 +508,19 @@ function _pbug_assemble_core(ψ, H, t, dt, alg::AbstractParallelBUG; imaginary_e end C̄L = Cevo[L] - # phase 2: leaves→root augmentation sweep, threading the mixed ⟨augmented|H|old⟩ environments. - # Alongside, accumulate `ηh = max‖C̃ᵢ‖ = h·η`: each coupling block `C̃ᵢ` is the frozen derivative - # projected onto the new directions (the normal component the old basis misses), so its norm is - # the local error estimator of Ceruti-Kusch-Lubich (2024, eq. 6), specialized to the caterpillar. + # phase 2: leaves→root augmentation, threading the mixed ⟨augmented|H|old⟩ envs. `ηh = max‖C̃ᵢ‖` is + # the local error estimator (Ceruti-Kusch-Lubich 2024, eq. 6): the frozen derivative projected onto + # the new directions the old basis misses. As = Vector{typeof(ψ₀.AL[1])}(undef, L) ηh = zero(real(scalartype(ψ₀))) - GLmix = _pbug_mixedenv_init(H, envs₀, ψ₀) # full augmented rows (trivial at the left edge) - local GLnew # new-direction rows only + GLmix = _pbug_mixedenv_init(H, envs₀, ψ₀) + local GLnew for i in 1:(L - 1) if i == 1 C⁰, Ĉ¹ = ψ₀.AL[1], Cevo[1] else - # first-order coupling block on the new rows of bond i-1. Applied with the two-arg - # (time-dependent) form at the midpoint `t + dt/2`, matching the anchor `integrate` - # freezes the coefficients at (integrators.jl); a `TimedOperator` has no one-arg apply. + # first-order coupling block on the new rows of bond i-1 (two-arg apply at midpoint + # `t + dt/2`, matching what `integrate` freezes; a `TimedOperator` has no one-arg apply) C̃ = scale(_pbug_coupling_hamiltonian(GLnew, H, i, envs₀, ψ₀)(ψ₀.AC[i], t + dt / 2), dt′) ηh = max(ηh, norm(C̃)) C⁰ = _pbug_stack_child(ψ₀.AL[i], zerovector!(similar(C̃))) @@ -650,8 +540,8 @@ function _pbug_assemble_core(ψ, H, t, dt, alg::AbstractParallelBUG; imaginary_e return FiniteMPS(As; overwrite = true, normalize = imaginary_evolution), ηh end -# Warm the lazily-cached frozen environments serially so the parallel phase-1 solves only read -# them: `leftenv`/`rightenv` on `FiniteEnvironments` mutate their cache. A `LazySum` yields a +# warm the lazily-cached frozen environments serially (leftenv/rightenv on `FiniteEnvironments` mutate +# their cache), so the parallel phase-1 solves only read them. A `LazySum` yields a # `MultipleEnvironments`, so recurse into its per-summand `FiniteEnvironments`. function _pbug_warmup_envs!(envs::FiniteEnvironments, L, ψ₀) for i in 1:L @@ -665,30 +555,24 @@ function _pbug_warmup_envs!(envs::MultipleEnvironments, L, ψ₀) return envs end -# Mixed ⟨augmented|H|old⟩ left environments: initial (trivial-bond) environment and one-site -# transfer step, with the same operator dispatch as the effective-Hamiltonian constructors. +# mixed ⟨augmented|H|old⟩ left environments: initial (trivial-bond) env and one-site transfer step, +# dispatching through `MultipliedOperator`/`LazySum` like the effective-Hamiltonian constructors. _pbug_mixedenv_init(H, envs, ψ₀) = leftenv(envs, 1, ψ₀) -function _pbug_mixedenv_init(H::MultipliedOperator, envs, ψ₀) - return _pbug_mixedenv_init(H.op, envs, ψ₀) -end +_pbug_mixedenv_init(H::MultipliedOperator, envs, ψ₀) = _pbug_mixedenv_init(H.op, envs, ψ₀) function _pbug_mixedenv_init(H::LazySum, envs::MultipleEnvironments, ψ₀) return map((o, e) -> _pbug_mixedenv_init(o, e, ψ₀), H.ops, envs.envs) end _pbug_mixedenv_step(GL, H, i, above, below) = GL * TransferMatrix(above, H[i], below) -function _pbug_mixedenv_step(GL, H::MultipliedOperator, i, above, below) - return _pbug_mixedenv_step(GL, H.op, i, above, below) -end +_pbug_mixedenv_step(GL, H::MultipliedOperator, i, above, below) = + _pbug_mixedenv_step(GL, H.op, i, above, below) function _pbug_mixedenv_step(GLs::Vector, H::LazySum, i, above, below) return map((gl, o) -> _pbug_mixedenv_step(gl, o, i, above, below), GLs, H.ops) end -# One-site effective derivative with the mixed new-direction rows as left environment and the -# frozen old right environment: applied to the center `AC[i]` this yields the first-order coupling -# block `C̃ᵢ` (Alg. 4's `h·F(Y_τ⁰) ×ⱼ U⁰* ×ᵢ Ũ¹*`). -function _pbug_coupling_hamiltonian(GL, H, i, envs, ψ₀) - return MPO_AC_Hamiltonian(GL, H[i], rightenv(envs, i, ψ₀)) -end +# one-site effective derivative with the mixed new-direction rows as left env and the frozen old right +# env: applied to the center `AC[i]` this yields the first-order coupling block `C̃ᵢ`. +_pbug_coupling_hamiltonian(GL, H, i, envs, ψ₀) = MPO_AC_Hamiltonian(GL, H[i], rightenv(envs, i, ψ₀)) function _pbug_coupling_hamiltonian(GL, H::MultipliedOperator, i, envs, ψ₀) return MultipliedOperator(_pbug_coupling_hamiltonian(GL, H.op, i, envs, ψ₀), H.f) end @@ -698,28 +582,26 @@ function _pbug_coupling_hamiltonian(GLs::Vector, H::LazySum, i, envs::MultipleEn return LazySum{elT}(Hs) end -# new bond directions: the component of the evolved (stacked) candidate orthogonal to the old -# basis, re-orthonormalized. Returns the isometry `Ũ` and the new bond space. +# new bond directions: the component of the evolved (stacked) candidate orthogonal to the old basis, +# re-orthonormalized. NOTE: keep `left_null` here — swapping it for `project_complement!`+QR adds +# completion columns outside the old-basis complement and breaks two-site exactness / first order. function _pbug_newdirs(AL, Cevo, alg_orth = Defaults.alg_orth()) N = left_null(AL) g = N' * Cevo Q, _ = left_orth(g; alg = alg_orth) - Ũ = N * Q - return Ũ, domain(Q) + return N * Q, domain(Q) end # stack two MPS tensors along the child (left-virtual) bond: `[top; bot]`, doubling that bond _pbug_stack_child(top, bot) = _transpose_front(catcodomain(_transpose_tail(top), _transpose_tail(bot))) -# Cut the augmented bonds back down. A truncating `trscheme` selects rank-adaptivity; the default -# `notrunc()` restores the pre-step virtual space of every bond (fixed-rank parallel BUG). -# Environments self-heal lazily for the changed bonds. +# cut the augmented bonds back down: a truncating `trscheme` selects rank-adaptivity, `notrunc()` +# restores the pre-step virtual space of every bond (fixed-rank). Environments self-heal lazily. function _pbug_truncate!(ϕ, alg::AbstractParallelBUG, Vs; normalize::Bool = false) if !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) changebonds!(ϕ, SvdCut(; trscheme = alg.trscheme, alg_svd = alg.alg_svd); normalize) else - # per-bond fixed-rank restore (mirrors the `SvdCut` sweep with a per-bond `trscheme`) for i in (length(ϕ) - 1):-1:1 U, S, Vᴴ = svd_trunc(ϕ.C[i]; trunc = truncspace(Vs[i]), alg = alg.alg_svd) ϕ.AC[i] = (ϕ.AL[i] * U, S) @@ -730,14 +612,6 @@ function _pbug_truncate!(ϕ, alg::AbstractParallelBUG, Vs; normalize::Bool = fal return ϕ end -# overwrite the internal representation of `ψ` with that of `ϕ` (same length, possibly new bonds) -function _pbug_overwrite!(ψ::FiniteMPS, ϕ::FiniteMPS) - for f in (:ALs, :ARs, :ACs, :Cs) - copyto!(getfield(ψ, f), getfield(ϕ, f)) - end - return ψ -end - # copying version, shared by both BUG integrators function timestep( ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, From 9e5ceb480aedfba98809691c8d4af42e530af85a Mon Sep 17 00:00:00 2001 From: lkdvos Date: Mon, 13 Jul 2026 20:30:28 -0400 Subject: [PATCH 20/23] normalization --- src/algorithms/timestep/bug.jl | 52 ++++++++++-------- src/algorithms/timestep/tdvp.jl | 44 +++++++++------ src/algorithms/timestep/time_evolve.jl | 14 ++++- test/algorithms/bug.jl | 8 +-- test/algorithms/parallelbug.jl | 4 +- test/algorithms/timestep.jl | 76 +++++++++++++++++++++++--- 6 files changed, 142 insertions(+), 56 deletions(-) diff --git a/src/algorithms/timestep/bug.jl b/src/algorithms/timestep/bug.jl index 947b039d1..dd1e0e5cf 100644 --- a/src/algorithms/timestep/bug.jl +++ b/src/algorithms/timestep/bug.jl @@ -15,8 +15,10 @@ The bond dimension grows and shrinks automatically to track the entanglement; `n the fixed-rank integrator. !!! note - Real-time evolution does not normalize the resulting state, so the state norm reflects the - accumulated truncation error. Imaginary-time evolution renormalizes after every half-sweep. + By default the state is not renormalized, so the norm keeps useful information (the + accumulated truncation error in real time, or the decaying weight in imaginary time). + Pass `normalize = true` to `timestep`/`time_evolve` to renormalize after every half-sweep + instead. This is independent of `imaginary_evolution`. ## Fields @@ -60,7 +62,7 @@ end function timestep!( ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::BUG, envs::AbstractMPSEnvironments = environments(ψ, H, ψ); - imaginary_evolution::Bool = false + imaginary_evolution::Bool = false, normalize::Bool = false ) # symmetric 2nd-order: a dt/2 left→right half-sweep composed with its dt/2 mirror L = length(ψ) @@ -89,9 +91,9 @@ function timestep!( AC_hamiltonian(L, ψ, H, ψ, envs), _mul_front(T, ψ_old.AC[L]), t, h, alg.integrator; imaginary_evolution ) - imaginary_evolution && normalize!(AC) + normalize && normalize!(AC) ψ.AC[L] = AC - truncates && changebonds!(ψ, svdcut; normalize = imaginary_evolution) + truncates && changebonds!(ψ, svdcut; normalize) # right→left half-sweep (root = first site), the mirror ψ.AC[L] # gauge center to site L @@ -114,9 +116,9 @@ function timestep!( AC_hamiltonian(1, ψ, H, ψ, envs), ψ_old.AC[1] * T, t + h, h, alg.integrator; imaginary_evolution ) - imaginary_evolution && normalize!(AC) + normalize && normalize!(AC) ψ.AC[1] = AC - truncates && changebonds!(ψ, svdcut; normalize = imaginary_evolution) + truncates && changebonds!(ψ, svdcut; normalize) return ψ, envs end @@ -165,8 +167,10 @@ saturates the doubling on some bond is recomputed as two half-steps (cf. Ceruti This integrator is **work in progress**; the API and behaviour may change. !!! note - Real-time evolution does not normalize the resulting state, so the state norm reflects the - accumulated truncation error. Imaginary-time evolution renormalizes after every step. + By default the state is not renormalized, so the norm keeps useful information (the + accumulated truncation error in real time, or the decaying weight in imaginary time). + Pass `normalize = true` to `timestep`/`time_evolve` to renormalize after every step + instead. This is independent of `imaginary_evolution`. ## Fields @@ -243,8 +247,10 @@ evolution (no backward substep). are supported (no `LazySum` / time-dependent operators). !!! note - Real-time evolution does not normalize the resulting state, so the state norm reflects the - accumulated truncation error. Imaginary-time evolution renormalizes after every step. + By default the state is not renormalized, so the norm keeps useful information (the + accumulated truncation error in real time, or the decaying weight in imaginary time). + Pass `normalize = true` to `timestep`/`time_evolve` to renormalize after every step + instead. This is independent of `imaginary_evolution`. ## Fields @@ -301,14 +307,14 @@ end function timestep!( ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::AbstractParallelBUG, envs::AbstractMPSEnvironments = environments(ψ, H, ψ); - imaginary_evolution::Bool = false + imaginary_evolution::Bool = false, normalize::Bool = false ) L = length(ψ) if L == 1 # single site: a plain forward center step AC = integrate( AC_hamiltonian(1, ψ, H, ψ, envs), ψ.AC[1], t, dt, alg.integrator; imaginary_evolution ) - imaginary_evolution && normalize!(AC) + normalize && normalize!(AC) ψ.AC[1] = AC return ψ, envs end @@ -316,9 +322,9 @@ function timestep!( truncates = !(alg.trscheme isa MatrixAlgebraKit.NoTruncation) Vs = [right_virtualspace(ψ, b) for b in 1:(L - 1)] # pre-step spaces, for the fixed-rank restore - ϕ, ηh = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution) + ϕ, ηh = _pbug_assemble(ψ, H, t, dt, alg; imaginary_evolution, normalize) augVs = [right_virtualspace(ϕ, b) for b in 1:(L - 1)] # the (doubled) augmented spaces - _pbug_truncate!(ϕ, alg, Vs; normalize = imaginary_evolution) + _pbug_truncate!(ϕ, alg, Vs; normalize) # step rejection (opt-in): a bond that kept its full augmented space was under-resolved by a # single doubling; recompute as two half-steps so one doubling per sub-step suffices. @@ -329,8 +335,8 @@ function timestep!( @debug "ParallelBUG step" ηh saturated if saturated alg′ = _pbug_with_rejections(alg, alg.maxiter_rejection - 1) - timestep!(ψ, H, t, dt / 2, alg′; imaginary_evolution) - timestep!(ψ, H, t + dt / 2, dt / 2, alg′; imaginary_evolution) + timestep!(ψ, H, t, dt / 2, alg′; imaginary_evolution, normalize) + timestep!(ψ, H, t + dt / 2, dt / 2, alg′; imaginary_evolution, normalize) return ψ, environments(ψ, H, ψ) end end @@ -419,7 +425,7 @@ end # envs, freezing `V̂0`; (3) a leaves→root sweep builds the `4r` isometries `[Û0 | Ũ2]` and transports # the evolved-amplitude coupling `R = Ũ2ᵀĈ` through the frozen `V̂0`; (4) the root stacks the `2r` # Galerkin `Kevo[L]` with the transported coupling, keeping the "new–new" corner zero (local `O(dt³)`). -function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bool = false) +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bool = false, normalize::Bool = false) L = length(ψ) ψ.AC[L] # gauge to the root ψ₀ = copy(ψ) @@ -478,7 +484,7 @@ function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG2; imaginary_evolution::Bo ηh = max(ηh, norm(C̃L)) As[L] = _pbug_stack_child(Kevo[L], C̃L) - return FiniteMPS(As; overwrite = true, normalize = imaginary_evolution), ηh + return FiniteMPS(As; overwrite = true, normalize), ηh end # First-order parallel-BUG assembly (Ceruti et al. 2024, arXiv:2412.00858, Alg. 1-4) rooted at site @@ -487,7 +493,7 @@ end # block* `C̃ᵢ` on the previous bond's new rows (`[old │ Ũᵢ]`), propagating deep new directions to the # root. Interior tensors are pure isometries (amplitude/phase discarded); all first-order + amplitude # content enters once, at the root `[C̄_L; C̃_L]`. The zero "new–new" corners make it first order. -function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false) +function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Bool = false, normalize::Bool = false) L = length(ψ) ψ.AC[L] # gauge to the root ψ₀ = copy(ψ) @@ -537,7 +543,7 @@ function _pbug_assemble(ψ, H, t, dt, alg::ParallelBUG; imaginary_evolution::Boo ηh = max(ηh, norm(C̃L)) As[L] = _pbug_stack_child(C̄L, C̃L) - return FiniteMPS(As; overwrite = true, normalize = imaginary_evolution), ηh + return FiniteMPS(As; overwrite = true, normalize), ηh end # warm the lazily-cached frozen environments serially (leftenv/rightenv on `FiniteEnvironments` mutate @@ -616,7 +622,7 @@ end function timestep( ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, alg::Union{BUG, ParallelBUG, ParallelBUG2}, envs::AbstractMPSEnvironments...; - imaginary_evolution::Bool = false, kwargs... + imaginary_evolution::Bool = false, normalize::Bool = false, kwargs... ) isreal = (scalartype(ψ) <: Real && !imaginary_evolution) ψ′ = isreal ? complex(ψ) : copy(ψ) @@ -629,5 +635,5 @@ function timestep( @assert length(envs) == 0 "Invalid signature" envs′ = environments(ψ′, H, ψ′) end - return timestep!(ψ′, H, time, timestep, alg, envs′; imaginary_evolution, kwargs...) + return timestep!(ψ′, H, time, timestep, alg, envs′; imaginary_evolution, normalize, kwargs...) end diff --git a/src/algorithms/timestep/tdvp.jl b/src/algorithms/timestep/tdvp.jl index 993782e54..afaf3ba4e 100644 --- a/src/algorithms/timestep/tdvp.jl +++ b/src/algorithms/timestep/tdvp.jl @@ -11,10 +11,11 @@ the enlarged bond back down (selecting the truncated-SVD gauge). The expansion i state-preserving, as required for a consistent time evolution. !!! note - Real-time evolution preserves the norm: neither the bond expansion nor the truncation - renormalizes, so the state norm reflects the accumulated truncation error. Imaginary-time - evolution instead renormalizes at every step, like a ground-state search. CBE is only - available for finite MPS. + By default the norm is preserved: neither the bond expansion nor the truncation + renormalizes, so the state norm keeps useful information (the accumulated truncation + error in real time, or the decaying weight in imaginary time). Pass `normalize = true` + to `timestep`/`time_evolve` to renormalize at every step instead, like a ground-state + search. This is independent of `imaginary_evolution`. CBE is only available for finite MPS. ## Fields @@ -61,11 +62,14 @@ end function timestep( ψ::InfiniteMPS, H, t::Number, dt::Number, alg::TDVP, envs::AbstractMPSEnvironments = environments(ψ, H, ψ); - leftorthflag = true, imaginary_evolution::Bool = false + leftorthflag = true, imaginary_evolution::Bool = false, normalize::Bool = false ) + # `normalize` is accepted for signature uniformity with the finite integrators, but an + # `InfiniteMPS` is always normalized to norm-1-per-site by the gauge/reconstruction below + # (a structural gauge requirement, not information erasure), so the flag has no effect here. # convert state to complex if necessary if scalartype(ψ) <: Real && (!imaginary_evolution || !isreal(dt)) - return timestep(complex(ψ), H, t, dt, alg, envs; leftorthflag, imaginary_evolution) + return timestep(complex(ψ), H, t, dt, alg, envs; leftorthflag, imaginary_evolution, normalize) end temp_ACs = similar(ψ.AC) @@ -120,23 +124,23 @@ end function timestep!( ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::TDVP, envs::AbstractMPSEnvironments = environments(ψ, H, ψ); - imaginary_evolution::Bool = false + imaginary_evolution::Bool = false, normalize::Bool = false ) # sweep left to right for i in 1:(length(ψ) - 1) # 1. optionally expand the bond ahead of the local update (CBE) isnothing(alg.alg_expand) || - changebond!(i, Val(:right), ψ, H, alg.alg_expand, envs; normalize = imaginary_evolution) + changebond!(i, Val(:right), ψ, H, alg.alg_expand, envs; normalize) # 2. evolve the (possibly expanded) center tensor forward Hac = AC_hamiltonian(i, ψ, H, ψ, envs) AC = integrate(Hac, ψ.AC[i], t, dt / 2, alg.integrator; imaginary_evolution) # 3. gauge: split AC -> AL[i], C[i] (QR center-move, or truncated SVD cutting the - # enlarged bond back down) and move the center to i+1. Real-time evolution preserves - # the norm; imaginary-time evolution renormalizes. - left_gauge!(ψ, i, AC, alg.alg_gauge; normalize = imaginary_evolution) + # enlarged bond back down) and move the center to i+1. By default the norm is + # preserved; `normalize` renormalizes. + left_gauge!(ψ, i, AC, alg.alg_gauge; normalize) # 4. evolve the bond tensor backward Hc = C_hamiltonian(i, ψ, H, ψ, envs) @@ -154,7 +158,7 @@ function timestep!( for i in length(ψ):-1:2 # 1. optionally expand the bond ahead of the local update (CBE) isnothing(alg.alg_expand) || - changebond!(i, Val(:left), ψ, H, alg.alg_expand, envs; normalize = imaginary_evolution) + changebond!(i, Val(:left), ψ, H, alg.alg_expand, envs; normalize) # 2. evolve the (possibly expanded) center tensor forward Hac = AC_hamiltonian(i, ψ, H, ψ, envs) @@ -163,9 +167,9 @@ function timestep!( imaginary_evolution ) - # 3. gauge: split AC -> C[i-1], AR[i] and move the center to i-1 (real-time preserves the - # norm; imaginary-time renormalizes) - right_gauge!(ψ, i, AC, alg.alg_gauge; normalize = imaginary_evolution) + # 3. gauge: split AC -> C[i-1], AR[i] and move the center to i-1 (norm preserved by + # default; `normalize` renormalizes) + right_gauge!(ψ, i, AC, alg.alg_gauge; normalize) # 4. evolve the bond tensor backward Hc = C_hamiltonian(i - 1, ψ, H, ψ, envs) @@ -221,7 +225,7 @@ end function timestep!( ψ::AbstractFiniteMPS, H, t::Number, dt::Number, alg::TDVP2, envs::AbstractMPSEnvironments = environments(ψ, H, ψ); - imaginary_evolution::Bool = false + imaginary_evolution::Bool = false, normalize::Bool = false ) # sweep left to right @@ -231,6 +235,9 @@ function timestep!( ac2′ = integrate(Hac2, ac2, t, dt / 2, alg.integrator; imaginary_evolution) nal, nc, nar = svd_trunc!(ac2′; trunc = alg.trscheme, alg = alg.alg_svd) + # `nc` is the norm-carrying bond tensor (`nal`/`nar` are isometries), so normalizing it + # normalizes the whole state, mirroring single-site TDVP's per-gauge renormalization + normalize && normalize!(nc) ψ.AC[i] = (nal, complex(nc)) ψ.AC[i + 1] = (complex(nc), _transpose_front(nar)) @@ -250,6 +257,7 @@ function timestep!( ac2′ = integrate(Hac2, ac2, t + dt / 2, dt / 2, alg.integrator; imaginary_evolution) nal, nc, nar = svd_trunc!(ac2′; trunc = alg.trscheme, alg = alg.alg_svd) + normalize && normalize!(nc) ψ.AC[i - 1] = (nal, complex(nc)) ψ.AC[i] = (complex(nc), _transpose_front(nar)) @@ -269,7 +277,7 @@ end function timestep( ψ::AbstractFiniteMPS, H, time::Number, timestep::Number, alg::Union{TDVP, TDVP2}, envs::AbstractMPSEnvironments...; - imaginary_evolution::Bool = false, kwargs... + imaginary_evolution::Bool = false, normalize::Bool = false, kwargs... ) isreal = (scalartype(ψ) <: Real && !imaginary_evolution) ψ′ = isreal ? complex(ψ) : copy(ψ) @@ -282,5 +290,5 @@ function timestep( @assert length(envs) == 0 "Invalid signature" envs′ = environments(ψ′, H, ψ′) end - return timestep!(ψ′, H, time, timestep, alg, envs′; imaginary_evolution, kwargs...) + return timestep!(ψ′, H, time, timestep, alg, envs′; imaginary_evolution, normalize, kwargs...) end diff --git a/src/algorithms/timestep/time_evolve.jl b/src/algorithms/timestep/time_evolve.jl index 51288abd7..a321520c0 100644 --- a/src/algorithms/timestep/time_evolve.jl +++ b/src/algorithms/timestep/time_evolve.jl @@ -20,6 +20,11 @@ through each of the time points obtained by iterating t_span. instead, (i.e. ``\\exp(-Hdt)`` instead of ``\\exp(-iHdt)``). This can be useful for using this function to compute the ground state of a Hamiltonian, or to compute finite-temperature properties of a system. +- `normalize::Bool=false`: if true, the state is renormalized after every step. This is + independent of `imaginary_evolution`: by default the norm is preserved, so it retains + useful information (the accumulated truncation error in real time, or the decaying weight + in imaginary time). Pass `true` to renormalize each step, e.g. when computing a ground + state or thermal state via imaginary-time evolution. """ function time_evolve end, function time_evolve! end @@ -27,7 +32,7 @@ for (timestep, time_evolve) in zip((:timestep, :timestep!), (:time_evolve, :time @eval function $time_evolve( ψ, H, t_span::AbstractVector{<:Number}, alg, envs = environments(ψ, H, ψ); - verbosity::Int = 0, imaginary_evolution::Bool = false + verbosity::Int = 0, imaginary_evolution::Bool = false, normalize::Bool = false ) log = IterLog("TDVP") LoggingExtras.withlevel(; verbosity) do @@ -36,7 +41,7 @@ for (timestep, time_evolve) in zip((:timestep, :timestep!), (:time_evolve, :time t = t_span[iter] dt = t_span[iter + 1] - t - ψ, envs = $timestep(ψ, H, t, dt, alg, envs; imaginary_evolution) + ψ, envs = $timestep(ψ, H, t, dt, alg, envs; imaginary_evolution, normalize) ψ, envs = alg.finalize(t, ψ, H, envs)::Tuple{typeof(ψ), typeof(envs)} @infov 3 logiter!(log, iter, 0, t) @@ -69,6 +74,11 @@ solving the Schroedinger equation: ``i ∂ψ/∂t = H ψ``. instead, (i.e. ``\\exp(-Hdt)`` instead of ``\\exp(-iHdt)``). This can be useful for using this function to compute the ground state of a Hamiltonian, or to compute finite-temperature properties of a system. +- `normalize::Bool=false`: if true, the state is renormalized after the step. This is + independent of `imaginary_evolution`: by default the norm is preserved, so it retains + useful information (the accumulated truncation error in real time, or the decaying weight + in imaginary time). Pass `true` to renormalize, e.g. when computing a ground state or + thermal state via imaginary-time evolution. """ function timestep end, function timestep! end diff --git a/test/algorithms/bug.jl b/test/algorithms/bug.jl index 03cbf0f30..d635ebd66 100644 --- a/test/algorithms/bug.jl +++ b/test/algorithms/bug.jl @@ -87,7 +87,7 @@ using Random E_start = real(expectation_value(ψi, H)) E_prev = E_start for _ in 1:20 - ψi, = timestep(ψi, H, 0.0, 0.1, BUG(); imaginary_evolution = true) + ψi, = timestep(ψi, H, 0.0, 0.1, BUG(); imaginary_evolution = true, normalize = true) E_now = real(expectation_value(ψi, H)) @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy E_prev = E_now @@ -220,7 +220,7 @@ end Dstart = maximum(dim(left_virtualspace(ψ, k)) for k in 1:L) E_start = real(expectation_value(ψ, H)) for _ in 1:30 - ψ, = timestep(ψ, H, 0.0, 0.1, BUG(; trscheme = truncerror(; atol = 1.0e-8)); imaginary_evolution = true) + ψ, = timestep(ψ, H, 0.0, 0.1, BUG(; trscheme = truncerror(; atol = 1.0e-8)); imaginary_evolution = true, normalize = true) end Dend = maximum(dim(left_virtualspace(ψ, k)) for k in 1:L) E_end = real(expectation_value(ψ, H)) @@ -331,7 +331,7 @@ end E_start = real(expectation_value(ψi, H)) E_prev = E_start for _ in 1:15 - ψi, = timestep(ψi, H, 0.0, 0.1, BUG(); imaginary_evolution = true) + ψi, = timestep(ψi, H, 0.0, 0.1, BUG(); imaginary_evolution = true, normalize = true) E_now = real(expectation_value(ψi, H)) @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy E_prev = E_now @@ -512,7 +512,7 @@ end ψ = ψ₀ for _ in 1:30 - ψ, = timestep(ψ, H, 0.0, 0.1, BUG(; trscheme = truncerror(; atol = 1.0e-8)); imaginary_evolution = true) + ψ, = timestep(ψ, H, 0.0, 0.1, BUG(; trscheme = truncerror(; atol = 1.0e-8)); imaginary_evolution = true, normalize = true) end Dend = maxbond(ψ) E_end = real(expectation_value(ψ, H)) diff --git a/test/algorithms/parallelbug.jl b/test/algorithms/parallelbug.jl index 35d14a21c..91a5aa1f2 100644 --- a/test/algorithms/parallelbug.jl +++ b/test/algorithms/parallelbug.jl @@ -71,7 +71,7 @@ using Random for _ in 1:20 ψi, = timestep( ψi, H, 0.0, 0.1, ParallelBUG(; trscheme = truncerror(; atol = 1.0e-10)); - imaginary_evolution = true + imaginary_evolution = true, normalize = true ) E_now = real(expectation_value(ψi, H)) @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy @@ -418,7 +418,7 @@ end for _ in 1:20 ψi, = timestep( ψi, H, 0.0, 0.1, ParallelBUG2(; trscheme = truncerror(; atol = 1.0e-10)); - imaginary_evolution = true + imaginary_evolution = true, normalize = true ) E_now = real(expectation_value(ψi, H)) @test E_now ≤ E_prev + 1.0e-6 # monotone (non-increasing) energy diff --git a/test/algorithms/timestep.jl b/test/algorithms/timestep.jl index a846b2c5c..a841345f1 100644 --- a/test/algorithms/timestep.jl +++ b/test/algorithms/timestep.jl @@ -9,7 +9,7 @@ using Test, TestExtras using MPSKit using TensorKit using TensorKit: ℙ -using LinearAlgebra: norm +using LinearAlgebra: norm, Diagonal using Random verbosity_full = 5 @@ -122,8 +122,9 @@ end @test abs(dot(ref, cbe)) > abs(dot(ref, plain)) end - # the bond truncation must preserve the norm for real-time evolution (the norm reflects the - # discarded weight) and only renormalize for imaginary-time evolution + # by default (`normalize = false`) the bond truncation preserves the norm, so it reflects the + # discarded weight; `normalize = true` renormalizes each step. This is independent of + # `imaginary_evolution`. @testset "norm handling" begin Random.seed!(6) ψ₀ = complex(FiniteMPS(rand, Float64, L, ℙ^2, ℙ^Dstart)) @@ -132,15 +133,24 @@ end ψrt = ψ₀ for _ in 1:12 - ψrt, = timestep(ψrt, H, 0.0, 0.5, lossy) # real time + ψrt, = timestep(ψrt, H, 0.0, 0.5, lossy) # real time, norm preserved by default end @test norm(ψrt) < 1 - 1.0e-3 # truncation loss is not renormalized away + # imaginary-time, norm preserved by default: the weight is *not* pinned to unit norm + # (imaginary-time evolution rescales the state, so the norm drifts away from 1) ψit = ψ₀ for _ in 1:12 - ψit, = timestep(ψit, H, 0.0, 0.5, lossy; imaginary_evolution = true) # no external normalize! + ψit, = timestep(ψit, H, 0.0, 0.5, lossy; imaginary_evolution = true) end - @test norm(ψit) ≈ 1 atol = 1.0e-6 # imaginary-time renormalizes each step + @test abs(norm(ψit) - 1) > 1.0e-3 + + # imaginary-time with `normalize = true`: renormalized to unit norm each step + ψn = ψ₀ + for _ in 1:12 + ψn, = timestep(ψn, H, 0.0, 0.5, lossy; imaginary_evolution = true, normalize = true) + end + @test norm(ψn) ≈ 1 atol = 1.0e-6 end @testset "imaginary-time lowers energy" begin @@ -150,7 +160,7 @@ end E₀ = real(expectation_value(ψ₀, H)) ψ = ψ₀ for _ in 1:8 - ψ, = timestep(ψ, H, 0.0, 0.1, alg; imaginary_evolution = true) # gauge renormalizes + ψ, = timestep(ψ, H, 0.0, 0.1, alg; imaginary_evolution = true, normalize = true) end @test real(expectation_value(ψ, H)) < E₀ @test dim(left_virtualspace(ψ, L ÷ 2)) > Dstart @@ -182,3 +192,55 @@ end @test E₀ ≈ E atol = 1.0e-2 end end + +# A single spin flip on the fully polarized vacuum of the XX chain (`XY_model` with `g = 0`) +# propagates as a free particle hopping on the open L-site chain, so the real-time correlator +# has a closed form: +# C_nm(t) = ⟨vac| S^x_n e^{-iHt} S^x_m |vac⟩ = ¼ [exp(-i h t)]_{nm}, +# where the single-particle hopping matrix `h` is tridiagonal (`h_{i,i±1} = -1`) and diagonalizes +# analytically on the open chain: eigenvalues `ε_k = -2 cos(kπ/(L+1))` with eigenvectors +# `φ_k(n) = √(2/(L+1)) sin(nkπ/(L+1))`. Being a genuine two-time correlator, this also exercises +# the norm-preserving `normalize = false` default: the evolving state must NOT be renormalized. +@testset "dynamical correlator vs free-particle analytics" begin + L = 16 + m = 8 # source site (chain centre) + ns = 5:11 # measured sink sites + dt = 0.05 + ts = 0.0:dt:1.5 + + H = force_planar(XY_model(ComplexF64, Trivial; g = 0, L = L)) + + # fully polarized product state |↑…↑⟩ (basis index 1 on every site): a zero-energy eigenstate + Vp = ℙ^2 + Vl = oneunit(Vp) + ψvac = FiniteMPS([isometry(ComplexF64, Vl ⊗ Vp, Vl) for _ in 1:L]) + @test norm(H * ψvac) < 1.0e-10 # H annihilates the polarized vacuum ⇒ E₀ = 0, no bra phase + + # apply S^x at a single site (S^x = ½σ^x flips one spin ⇒ a single free particle) + Sx = force_planar(S_x()) + apply_Sx(i) = FiniteMPOHamiltonian(physicalspace(ψvac), i => Sx) * ψvac + ϕ_bra = Dict(n => apply_Sx(n) for n in ns) # static ½|n⟩ + ϕ = apply_Sx(m) # ½|m⟩, evolved below + + # analytic single-particle propagator exp(-i h t) on the open chain + ks = 1:L + ε = [-2 * cos(k * π / (L + 1)) for k in ks] + φ = [sqrt(2 / (L + 1)) * sin(n * k * π / (L + 1)) for n in 1:L, k in ks] + Uref(t) = φ * Diagonal(cis.(-ε .* t)) * transpose(φ) + Cref(n, t) = 0.25 * Uref(t)[n, m] + + # single-particle physics stays rank ≤ 2, so TDVP2 with a generous cap is essentially exact + alg = TDVP2(; trscheme = truncrank(6)) + t_prev = 0.0 + maxerr = 0.0 + for t in ts + t > 0 && ((ϕ, _) = timestep(ϕ, H, t_prev, t - t_prev, alg); t_prev = t) + for n in ns + maxerr = max(maxerr, abs(dot(ϕ_bra[n], ϕ) - Cref(n, t))) + end + end + @test maxerr < 1.0e-3 + + # the correlator retains the un-normalized amplitude: real-time evolution preserves the norm + @test norm(ϕ) ≈ norm(ϕ_bra[m]) atol = 1.0e-6 +end From cd2259a07232bf0256624409b9cc3dd9a03a4f7e Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 14 Jul 2026 09:55:31 -0400 Subject: [PATCH 21/23] fix kitaev_model --- test/setup/testsetup.jl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/setup/testsetup.jl b/test/setup/testsetup.jl index 053d272f3..fbe1bb25b 100644 --- a/test/setup/testsetup.jl +++ b/test/setup/testsetup.jl @@ -165,7 +165,7 @@ function kitaev_model( lattice = fill(space(TB, 1), L) onsite_terms = ((i,) => CP for i in 1:L) twosite_terms = ((i, i + 1) => TB + SC for i in 1:(L - 1)) - terms = Iterators.flatten(twosite_terms, onsite_terms) + terms = Iterators.flatten((twosite_terms, onsite_terms)) return FiniteMPOHamiltonian(lattice, terms) end end From 3b5ad99d1963919aa8021147489fe4af7987b496 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 14 Jul 2026 11:29:40 -0400 Subject: [PATCH 22/23] make test hamiltonian actually hermitian --- test/setup/testsetup.jl | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/setup/testsetup.jl b/test/setup/testsetup.jl index fbe1bb25b..8c2bfbd3f 100644 --- a/test/setup/testsetup.jl +++ b/test/setup/testsetup.jl @@ -11,7 +11,7 @@ using BlockTensorKit using LinearAlgebra: Diagonal using Combinatorics: permutations using TensorKitTensors.SpinOperators: S_x, S_y, S_z, S_x_S_x, S_y_S_y, S_z_S_z, S_exchange, S_plus_S_min, S_min_S_plus -using TensorKitTensors.FermionOperators: f_plus_f_min, f_min_f_plus, f_plus_f_plus, f_min_f_min, f_num +using TensorKitTensors.FermionOperators: f_plus_f_min, f_min_f_plus, f_plus_f_plus, f_min_f_min, f_num, f_hopping # exports export S_x, S_y, S_z @@ -154,8 +154,8 @@ function kitaev_model( T::Type{<:Number} = ComplexF64, sym::Type{<:Sector} = Trivial; t = 1.0, mu = 1.0, Delta = 1.0, L = Inf ) - TB = scale!(f_plus_f_min(T, sym) + f_min_f_plus(T, sym), -t / 2) # tight-binding term - SC = scale!(f_plus_f_plus(T, sym) + f_min_f_min(T, sym), Delta / 2) # superconducting term + TB = scale!(f_hopping(T, sym), -t / 2) # tight-binding term + SC = scale!(f_min_f_min(T, sym) - f_plus_f_plus(T, sym), Delta / 2) # superconducting term CP = scale!(f_num(T, sym), -mu) # chemical potential term if L == Inf From f95f38b296fa368171a094a82b7372c6197910b3 Mon Sep 17 00:00:00 2001 From: lkdvos Date: Tue, 14 Jul 2026 11:29:53 -0400 Subject: [PATCH 23/23] fermionic timestep tests --- test/algorithms/timestep.jl | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/test/algorithms/timestep.jl b/test/algorithms/timestep.jl index a841345f1..8f0fe730f 100644 --- a/test/algorithms/timestep.jl +++ b/test/algorithms/timestep.jl @@ -244,3 +244,53 @@ end # the correlator retains the un-normalized amplitude: real-time evolution preserves the norm @test norm(ϕ) ≈ norm(ϕ_bra[m]) atol = 1.0e-6 end + +# The same free-particle benchmark with an explicitly fermionic (fℤ₂-graded) model: a single +# fermion on the tight-binding chain (`kitaev_model` with `mu = Delta = 0`) is a free particle, so +# the single-particle Green's function is the closed-form propagator +# G_nm(t) = ⟨0| c_n e^{-iHt} c_m† |0⟩ = [exp(-i h t)]_{nm}, h_{i,i±1} = -t/2, +# with `ε_k = -t cos(kπ/(L+1))` and `φ_k(n) = √(2/(L+1)) sin(nkπ/(L+1))`. This exercises the graded +# fermionic tensors (Jordan-Wigner strings handled by the fℤ₂ braiding): from the empty vacuum, +# `c_m†|0⟩` is just the localised single-particle basis state (the string acts trivially on the +# vacuum), built here as a bond-1 MPS with a fermion-parity charge kink at site m. It relies on the +# hopping being Hermitian (`f_hopping`, not `f_plus_f_min + f_min_f_plus`), else the norm drifts. +@testset "fermionic dynamical correlator vs free-particle analytics" begin + L = 12 + m = 6 + ns = 4:8 + dt = 0.05 + ts = 0.0:dt:1.2 + + H = kitaev_model(ComplexF64, Trivial; t = 1.0, mu = 0, Delta = 0, L = L) + P = physicalspace(H)[1] # fermion_space = Vect[fℤ₂](0 => 1, 1 => 1) + S = typeof(P) + Vtriv = oneunit(P) + Vodd = S(c => dim(P, c) for c in sectors(P) if !isone(c)) + + # single-particle basis state |j⟩ = c_j†|0⟩: empty except occupied at j, with the fermion-parity + # kink even→odd at site j (even bond to the left, odd bond to the right, odd right boundary) + onepart(j) = FiniteMPS([ + isometry(ComplexF64, (k <= j ? Vtriv : Vodd) ⊗ P, (k < j ? Vtriv : Vodd)) for k in 1:L + ]) + @test norm(H * onepart(m)) ≈ sqrt(2) * 0.5 atol = 1.0e-8 # hops to both neighbours, amp t/2 + + ϕ_bra = Dict(n => onepart(n) for n in ns) + ϕ = onepart(m) + + ks = 1:L + ε = [-cos(k * π / (L + 1)) for k in ks] # -t cos(kπ/(L+1)) with t = 1 + φ = [sqrt(2 / (L + 1)) * sin(n * k * π / (L + 1)) for n in 1:L, k in ks] + Uref(t) = φ * Diagonal(cis.(-ε .* t)) * transpose(φ) + + alg = TDVP2(; trscheme = truncrank(6)) + t_prev = 0.0 + maxerr = 0.0 + for t in ts + t > 0 && ((ϕ, _) = timestep(ϕ, H, t_prev, t - t_prev, alg); t_prev = t) + for n in ns + maxerr = max(maxerr, abs(dot(ϕ_bra[n], ϕ) - Uref(t)[n, m])) + end + end + @test maxerr < 1.0e-3 + @test norm(ϕ) ≈ 1 atol = 1.0e-8 # real-time evolution preserves the norm +end