Skip to content

DMRG3S: strictly single-site DMRG subspace expansion (Hubig et al. 2015)#460

Draft
VinceNeede wants to merge 10 commits into
QuantumKitHub:mainfrom
VinceNeede:vb-dmrg3s
Draft

DMRG3S: strictly single-site DMRG subspace expansion (Hubig et al. 2015)#460
VinceNeede wants to merge 10 commits into
QuantumKitHub:mainfrom
VinceNeede:vb-dmrg3s

Conversation

@VinceNeede

Copy link
Copy Markdown

Summary

Implements DMRG3S — strictly single-site DMRG with subspace expansion — from Hubig, McCulloch, Schollwöck & Wolf, Phys. Rev. B 91, 155115 (2015). Unlike CBE (alg_expand), which enriches the bond before the local eigensolve, DMRG3S enriches it after, using a perturbation built from the just-optimized tensor, the local Hamiltonian, and the environment. This lets single-site DMRG escape local minima caused by missing quantum-number sectors that CBE-style pre-expansion can't reach.

This PR also targets TDVP, pending design agreement

DMRG only for now. TDVP support belongs in this same PR, but I'd like design agreement on the interface below first, since it directly shapes how it plugs into TDVP too. Docs are also on hold for the same reason — no point documenting an interface that might still change.

What's here

  • Refactored find_groundstate!'s left-to-right and right-to-left sweeps from two separate, near-duplicate loops into a single loop over direction in (:right, :left), dispatching on Val. No behavioral change; this just made room for the new expansion step without doubling its code.
  • alg_post_expand field on DMRG, dispatching a new post_expand! step after the eigensolve, alongside the existing pre-eigensolve alg_expand. Default (NoExpand()) reproduces current behavior exactly.
  • DMRG3S, with a composable NoiseSchedule for the mixing factor (ExponentialDecay, Warmup, for combining them).
  • alg_expand and alg_post_expand are independent and can be used together or separately.
  • A warning fires if either expansion mechanism is active without a truncating alg_gauge, since the bond would otherwise grow unboundedly each sweep.
  • Tests: a bond-growth smoke test alongside the existing CBE tests, and a reproduction of the paper's Sec. VII A example (plain single-site DMRG gets stuck at E ≈ -6.35, DMRG3S recovers E ≈ -8.68).

Open design questions for maintainers

  1. alg_post_expand sentinel: I used a dedicated NoExpand() type rather than nothing (which alg_expand uses). This lets NoExpand carry its own post_expand! method for dispatch, but it's an inconsistency with alg_expand's convention worth flagging.
  2. Expand+gauge+neighbor-pad are fused into one post_expand! call rather than kept separate, because splitting them left the MPS briefly inconsistent between steps (caught via a real bug during development — happy to share details). This does mean post_expand! needs alg_gauge threaded in as a parameter, coupling it to the gauge step more tightly than alg_expand is.

TODO before merge

  • Docs
  • TDVP support
  • Decide on Q1–Q2 above

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Your PR no longer requires formatting changes. Thank you for your contribution!

@codecov

codecov Bot commented Jul 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 74 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/algorithms/post_expand/dmrg3s.jl 0.00% 50 Missing ⚠️
src/algorithms/groundstate/dmrg.jl 0.00% 18 Missing ⚠️
src/algorithms/post_expand/post_expand.jl 0.00% 5 Missing ⚠️
src/algorithms/approximate/fvomps.jl 0.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/MPSKit.jl 100.00% <ø> (ø)
src/algorithms/approximate/fvomps.jl 0.00% <0.00%> (-91.31%) ⬇️
src/algorithms/post_expand/post_expand.jl 0.00% <0.00%> (ø)
src/algorithms/groundstate/dmrg.jl 0.00% <0.00%> (-95.24%) ⬇️
src/algorithms/post_expand/dmrg3s.jl 0.00% <0.00%> (ø)

... and 79 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@lkdvos

lkdvos commented Jul 15, 2026

Copy link
Copy Markdown
Member

Thanks for this, I had a brief look and I think it already looks quite well.
I'll try and make some time for a proper review either tomorrow or the day after, but mostly I'm wondering if it might make sense to wait for #455, as that has a similar kind of refactor of the DMRG machinery to avoid duplication.

Considering the expansion algorithm itself - without having looked in a lot of detail so definitely feel free to tell me I'm wrong - I think it could be nice to avoid having both an alg_gauge and an alg_postexpand, and simply merge the two together. As always, naming things is really the hardest part, but it feels a bit clumsy to have the if-guard in the generic local step, and to have to pass 2 algorithms to the perturb! body.
Do you think it might be possible to simply put the alg_gauge inside of the DMRG3S, and then keep only one of these around that we dispatch on? I'm not sure how blasphemous it would be to call these both alg_gauge...
We might also try and discuss more carefully about the invalid state though, the FiniteMPS struct and the interaction with the environments is quite subtle, and it might still be that it is still fixable to separate the two cleanly as well.

For the TDVP implementation, I definitely agree to flush this one out first, and honestly it's probably easier to tackle and review that in a separate PR.

Considering your other question, I'm definitely happy to add the NoExpand, and since we are in the middle of a breaking cycle, I would be happy to change the alg_expand to use that too.

@lkdvos
lkdvos marked this pull request as ready for review July 15, 2026 00:24
@lkdvos
lkdvos marked this pull request as draft July 15, 2026 00:24
Add `FunctionalSchedule`, wrapping an arbitrary `(noise, iter, ϵ) -> noise`
callable as a `NoiseSchedule`. Overload `∘` on `NoiseSchedule` to compose
two schedules into a `FunctionalSchedule`, following the usual function
composition convention (`s2` applied first, `s1` applied to its result).

Add a `threshold` keyword to `ExponentialDecay`, snapping the decayed noise
to exactly zero once it falls below it -- avoids running the (cheap but
non-free) expansion step indefinitely on a vanishingly small amplitude.
This also makes `ExponentialDecay ∘ Warmup` a natural way to combine a
hard iteration cutoff with smooth decay.
@VinceNeede
VinceNeede force-pushed the vb-dmrg3s branch 2 times, most recently from 776835c to f2c34cc Compare July 20, 2026 13:50
@VinceNeede

Copy link
Copy Markdown
Author

Hi @lkdvos,

Pushed a sketch of merging alg_gauge/alg_post_expand along the lines you suggested. The dispatch side is in place: NoExpand/DMRG3S both wrap an inner gauge algorithm, gauge!/gauge2! dispatch on whichever one alg_gauge is, and local_update! ends up with a single unconditional gauge!(...) call — no if-guard, no second algorithm argument. The noise schedule advances once per outer iteration via _update_alg_gauge, threaded through find_groundstate!.

I haven't wired up DMRG's constructor yet, so this isn't runnable end-to-end through the public API — that's next, and here's the direction I'm planning to take it.

Since DMRG's trscheme/alg_svd/alg_orth also determine the inner gauge, a user could in principle construct DMRG3S with its own gauge already set (DMRG3S(noise, schedule, some_gauge)) and pass a different trscheme to DMRG, giving two conflicting sources for the same thing. My plan is to make trscheme/alg_svd/alg_orth keyword arguments of NoExpand/DMRG3S themselves, building the inner gauge there — rather than have them accessible from DMRG's constructor directly. That way each gauge object is self-contained, and the conflict can't arise in the first place. Let me know if you'd rather keep trscheme etc. as DMRG-level kwargs instead (with some other rule for resolving the conflict), otherwise I'll go ahead and wire it up this way.

@lkdvos lkdvos left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for rebasing onto that other work!

Considering the DMRG constructor, I think I agree that we should exclude being able to set conflicting schemes, but unless I'm missing something that would already not be possible - the gauging algorithm is either a (truncated) orth algorithm or DMRG3S, and in the latter case there is only a single (truncated) orth algorithm in the DMRG3S.
If at all possible, I would like to design the DMRG constructor to be able to take the trscheme still, and have that routed through automatically, or error if both a trscheme and DMRG3S are provided. My experience is that while these "algorithm structs" are very convenient as a developer, it is somewhat harder as a newcomer to the package to keep track of everything, and a simple keyword-argument approach, which possibly does not expose all settings but handles the common cases, helps alleviate some of these issues.

LoggingExtras.withlevel(; alg.verbosity) do
@infov 2 loginit!(log, ϵ_global, expectation_value(ψ, H, envs))
for iter in 1:(alg.maxiter)
alg_gauge = _update_alg_gauge(alg.alg_gauge, iter, ϵ_global)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A comment here but this involves the other parts of this code:

It might be beneficial to consider passing iter to the local_update! function, such that this alg update can be handled inside that function, similar to how that is handled for the dynamic eigensolver settings.
I very much like the warmup and decay options, so it might even make sense to also unify that into there anyways (in a follow-up PR for sure though, let's try and keep things moving).

The main reason I'm suggesting this is that it is slightly unfortunate to have to pass alg_gauge, even though that is already contained in alg, so it feels slightly more natural to me to pass iter.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

The reason I decided not to pass iter and instead pass alg_gauge is that alg_gauge is only updated once per sweep. Moving the update inside local_update! would mean calling it unnecessarily at every site. I could make the struct mutable to avoid the extra allocations, but there would still be the call overhead. Another option would be to have the scheduler update at each site instead of once per iteration — in the original formulation, it was actually supposed to monitor the change in energy before and after truncation and use that to adapt the noise.

Alternatively, we could update alg (the DMRG struct) in place each sweep instead of threading alg_gauge through as a separate argument — local_update! would then just read alg.alg_gauge directly, which avoids the extra parameter without needing to call the update per site. Either DMRG or DMRG3S would need to become mutable for this to work.

return iszero(noise) ? NoExpand(alg.alg_gauge) : DMRG3S(noise, alg.schedule, alg.alg_gauge)
end

function _get_combiner(::Type{T}, V1, V2) where {T}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this function is the same as fuser, which already exists somewhere in our utility.jl file I think. You can recover the fused space via only(domain(F)) I think.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I tried using fuser, but it is restricted to IndexSpace only, while right_virtualspace(H[pos]) returns something like BlockTensorKit.SumSpace{GradedSpace{ProductSector{Tuple{TensorKitSectors.PlanarTrivial, Z2Irrep}}, Tuple{Int64, Int64}}}
Could we extend the method? or is the isomorphism not uniquely defined in other cases?

V = right_virtualspace(AC)
combiner, Vpert = _get_combiner(T, V, right_virtualspace(Hi))

@plansor pert[-1 -2; -3] := α * El[-1 1; 2] * AC[2 3; 4] * Hi[1 -2; 3 5] * combiner[4, 5; -3]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this contraction might be the same as our AC_projection contractions, and it could be beneficial to reuse that - the main reason is that we also support density-matrix MPS, which have 2 physical legs and would break this contraction, and reusing that function automatically fixes that.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I already took a look at AC_projection, and from my understanding, it requires to supply all the enviroments, both left and right, here instead only one is required at a time. I'm not sure it can be adapted to this use case

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

yes, but the combiner can be thought of as an environment in this case, so while the code routing is a bit more complex, the contraction is the same as this one:

@plansor y[-1 -2; -3] ≔ h.leftenv[-1 5; 4] * x[4 2; 1] * h.operators[1][5 -2; 2 3] *
h.rightenv[1 3; -3]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants