Skip to content

Fix Multiline (MultilineMPS/MultilineMPO) correctness and API inconsistencies - #508

Open
borisdevos wants to merge 32 commits into
mainfrom
bd/multiline
Open

Fix Multiline (MultilineMPS/MultilineMPO) correctness and API inconsistencies#508
borisdevos wants to merge 32 commits into
mainfrom
bd/multiline

Conversation

@borisdevos

@borisdevos borisdevos commented Aug 12, 2026

Copy link
Copy Markdown
Member

[Relevant edit deeper in conversation]

I found a cluster of bugs that all traced back to the same root cause, namely Multiline{T} was inconsistent about what it is. Sometimes it was treated as a 2D array indexed by (row, col), and sometimes as a 1D sequence of T-typed lines, this while different parts of the code picked different conventions without checking consistency. The real bugs were:

  • MultilineMPO * MultilineMPS had never worked (straight up method error), and fixing that trivially still left both a shape bug (length counting rows*cols while map/collect only ever produced rows elements) and a silently wrong row-shift bug (fidelity 0 where it should be 1).
  • expectation_value(::MultilineMPS, ::MultilineMPO, ...) had a fallback method that silently returned something meaningless (prod instead of sum, no row shift, discarded envs which was noted through a todo) for any MultilineMPO line type it wasn't explicitly guarded against. Related to this, there was half-support for Hamiltonian or finite MPO multilines even though they're meaningless. Also the environments weren't being correctly converted, so they were being recalculated every row.
  • isfinite, changebonds(::MultilineMPO, ::SvdCut), and axes(m, 2) all threw outright.

A summary of the changes I made:

"Semantics":

  • Multiline{T} now consistently treats length/eltype/iterate/m[i] as referring to the lines (matching the periodic rows), while size/axes/eachindex refer to the (nrows, ncols) lattice shape. These two views are intentionally different (length(m) != prod(size(m))). I also documented this in docstrings.
  • Fixed multiplication: both were broken. The row-shifting is implemented and documented.
  • Deleted the expectation_value fallback. Changed the infinite MPS-MPO expectation value to correctly account for environments.
  • Fixed isfinite, changebonds(::MultilineMPO/::MultilineMPS, ::SvdCut), and axes(m, i).
  • Some grassmann code needed a change related to the 1d vs 2d view thing above.
  • Added the missing instance-level spacetype/sectortype/storagetype for multiline. I needed this for the custom show (see below).

Construction restrictions:

  • MultilineMPO only accepts InfiniteMPO lines now, enforced at constructor level. This is deliberate, since Multiline is also used via converts in environments, quasiparticles etc.
  • Removed the AbstractMatrix constructor that silently built finite-line MultilineMPOs, and the dead code in orthoview.jl that existed only to support finite MPS multilines.

Display:

  • MultilineMPS/MultilineMPO custom show: added a summary which looks at the 2d shape, and a show that renders each row using that row's own existing show method. Here below an example of how it looks now:

Here the multiline MPS:

Details
2×2 MultilineMPS(ComplexF64, Vect[IsingAnyon]) with maximal dimension 10.0:
row 1:
2-site InfiniteMPS(ComplexF64, Vect[IsingAnyon]) with maximal dimension 10.0:
| ⋮
| (:σ => 7)
├─[2]─ (:σ => 1)
│ (:I => 5, :ψ => 5)
├─[1]─ (:σ => 1)
│ (:σ => 7)
| ⋮

  ⋮
row 2:
2-site InfiniteMPS(ComplexF64, Vect[IsingAnyon]) with maximal dimension 10.0:
| ⋮
| (:I => 5, :ψ => 5)
├─[2]─ (:σ => 1)
│ (:σ => 7)
├─[1]─ (:σ => 1)
│ (:I => 5, :ψ => 5)
| ⋮

And here a multiline MPO:

Details
2×2 MultilineMPO(ComplexF64, Vect[IsingAnyon]) with maximal dimension 1.4142135623730951:
row 1:
2-site InfiniteMPO(ComplexF64, Vect[IsingAnyon]) with maximal dimension 1.4142135623730951:
| ⋮
| (:σ => 1)
┼─[2]─ (:σ => 1)
│ (:σ => 1)
┼─[1]─ (:σ => 1)
│ (:σ => 1)
| ⋮

  ↓  (row 1 maps onto row 2)
row 2:
2-site InfiniteMPO(ComplexF64, Vect[IsingAnyon]) with maximal dimension 1.4142135623730951:
| ⋮
| (:σ => 1)
┼─[2]─ (:σ => 1)
│ (:σ => 1)
┼─[1]─ (:σ => 1)
│ (:σ => 1)
| ⋮

  ↓  (row 2 maps onto row 1)

Tests:

  • New test/operators/multilinempo.jl: exact-fidelity tests for the row-shift convention in both MultilineMPO * MultilineMPS and MultilineMPO * MultilineMPO, using a permutation MPO whose fixed point is known exactly.
  • test/algorithms/statmech.jl: multi-row expectation_value regression test. Also a test that the Hamiltonian loophole above still correctly throws.
  • test/states/multilinemps.jl: added instance-level isfinite(ψ) (only the type-level form was covered before).

Documentation:

  • docs/src/man/states.md: added the row-shift convention, what's currently supported (infinite lines only), and subtleties.
  • docs/src/man/operators.md: new MultilineMPO section covering the same convention, and emphasising the MPO*MPO non-composability caveat.
  • Updated docstrings on Multiline, MultilineMPO, MultilineMPS, and expectation_value.
  • Added to the changelog, as the PR template wished of me 😄 .

Some things I didn't address because I'm not sure what to do with them:

  • VectorInterface stuff is still broken for MultilineMPS, simply because InfiniteMPS itself doesn't implement zerovector/scale.
  • norm(ψ) == sqrt(nrows) for a MultilineMPS of individually-normalized rows is unchanged. This goes through dot/inner sum over rows. I don't know if this is what you want.

Checklist

  • Tests pass locally (julia --project=test test/runtests.jl, or the relevant subset)
  • Documentation updated, if this PR changes public API (docstrings, docs/src/)
  • Runic formatter is run
  • Changelog entry added under [Unreleased] in docs/src/changelog.md, if this PR is user-facing (new feature, behavior change, bug fix, deprecation, or removal)

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 39.60396% with 61 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/utility/show.jl 0.00% 36 Missing ⚠️
src/states/orthoview.jl 0.00% 8 Missing ⚠️
src/utility/multiline.jl 50.00% 5 Missing ⚠️
src/algorithms/expval.jl 50.00% 4 Missing ⚠️
src/algorithms/changebonds/svdcut.jl 60.00% 2 Missing ⚠️
src/operators/multilinempo.jl 80.00% 2 Missing ⚠️
src/algorithms/grassmann.jl 75.00% 1 Missing ⚠️
src/algorithms/statmech/vomps.jl 75.00% 1 Missing ⚠️
src/environments/multiline_envs.jl 50.00% 1 Missing ⚠️
src/states/multilinemps.jl 75.00% 1 Missing ⚠️
Files with missing lines Coverage Δ
src/MPSKit.jl 100.00% <ø> (ø)
src/algorithms/changebonds/randexpand.jl 63.63% <100.00%> (+35.06%) ⬆️
src/algorithms/groundstate/vumps.jl 98.68% <100.00%> (+8.01%) ⬆️
src/algorithms/statmech/idmrg.jl 98.51% <100.00%> (+98.51%) ⬆️
src/algorithms/grassmann.jl 79.03% <75.00%> (+79.03%) ⬆️
src/algorithms/statmech/vomps.jl 87.93% <75.00%> (+87.93%) ⬆️
src/environments/multiline_envs.jl 80.35% <50.00%> (+80.35%) ⬆️
src/states/multilinemps.jl 79.54% <75.00%> (+32.87%) ⬆️
src/algorithms/changebonds/svdcut.jl 89.18% <60.00%> (+13.51%) ⬆️
src/operators/multilinempo.jl 69.56% <80.00%> (+49.56%) ⬆️
... and 4 more

... and 58 files with indirect coverage changes

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

@leburgel leburgel self-assigned this Aug 18, 2026
@borisdevos

Copy link
Copy Markdown
Member Author

After having discussed with @leburgel, I realised I didn't fully appreciate/understand what multiline actually represents, so recent changes rectify some mistakes I made, and tried to explain these niche things more cleanly in the docs/docstrings.

  • I now allow finite lines at type and constructor level, but there's still no support for these at the algorithmic level. Hamiltonians are still correctly rejected.
  • Multiplying multiline things never made sense, so I removed these methods and added the one that does make sense.
  • Some orthoview changes I previously removed, plus fixing bounds checking on these views.
  • I took the liberty of not calling the value that you would calculate with multiline an expectation value, but rather what it is, a dominant eigenvalue. So I introduced a new function dominant_eigenvalue, explained why it's separate from expectation_value and when these actually collide. The tricky thing here was that the VUMPS code worked for both MPOs and Hamiltonians, so there's a funky check there to see whether expectation values or dominant eigenvalues are being logged.
  • Docs and docstrings clarifications.

Comment thread docs/src/changelog.md
- `Multiline` (and therefore `MultilineMPS`/`MultilineMPO`) now consistently treats
`length`/`eltype`/`iterate`/`m[i]` as referring to the individual lines it stores
(`length(m) == nrows`), while `size`/`axes`/`eachindex` refer to the `(nrows, ncols)` lattice
shape.

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 is the change I have the most questions about. I like that you state that it is consistent, even though it is clearly inconsistent between most of these methods 😆 (length(x) == prod(size(x)) is now explicitly broken).

Am I understanding this correctly that you are effectively claiming this is interpreted as a vector of lines, with a little bit of syntactic sugar to make m[i, j] work and then have size follow the syntactic sugar?
With that in mind, I think most of the code is actually not calling m[i,j] at all, since for MPS that does not really mean anything (you'd need the .AC or something anyways), and for MPO I think we are mostly doing things by first unpacking the rows and only then indexing the sites.
Would it also work to actually pull this through all the way, just remove the lattice shape from the equation entirely?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It's consistent for me in the sense that when working with a multiline MPS what really makes it different is the layers of MPSs, so it made sense to me that length and eltype should refer to the layers. Also, m[i, j] is actually not for iMPS because we don't even allow slicing it (as you say, it doesn't make sense), though it is supported for iMPOs.

So yeah in a nutshell, I am indeed trying to make multiline behave like an AbstractVector at the level of iterating and index slicing correctly over its length, but it's not fully a subtype of that because you still want 2d cartesian indexing over something like m.AL. The initial motivation for this was that some map (among others) on a multiline MPS would previously make an nrows*ncols vector with only nrows defined entries.

Do you mean by removing the lattice shape to just treat multiline as a vector of lines? This is certainly doable, though every size(mpsi, 2) would need to be replaced by length(mpsi[1]), among others which rely on the 2d structure. If you think this is better, then might as well make Multiline{T} <: AbstractVector{T}, right?

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 if you want to change it and make it consistent, might as well make it behave like a vector of lines, indeed, and possibly just add a new name for size(mps, 2). It just really looks iffy to me to have both cartesian 2d indexing as well as linear indexing but have both of them not compatible, and I'm definitely fine with keeping m.AL alive, as that doesn't interfere with this, since it outputs a separate ALView, for which we can then separately decide to make it behave like a Matrix instead of a Vector.
TLDR, I think technically the current and previous approach are piracy, in the sense that we are stealing the AbstractArray interface and then incorrectly implementing it :)

Comment thread src/algorithms/expval.jl
# Dominant eigenvalues
# --------------------
"""
dominant_eigenvalue(ψ, O, [environments]) -> λ

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.

Can you maybe explain a bit better what is going on here? I am not really following most of the discussion here to be honest. Why is this not ⟨ϕ|O|ϕ⟩?
From what I can tell it really is that, in the sense that because you take the product over the transfer of all of the rows, you get exactly the overlap after taking it through an entire unit cell, so I don't really understand what is going on here.

I think I might get that it is not an expectation value in the sense that it is not the expectation value of an operator in the statmech sense, for which indeed you'd use dot(psi, O, phi) instead, but if expectation_value(psi, O) = dot(psi, O, psi) by definition, I don't immediately see the benefit of making this a separate function.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The motivation for this was indeed semantics, trying to stress the fact that it's a stat-mech method, and there the way to reason this eigenvalue is really from applying transfer operators onto a state which lives on one side of the partition function, say the top. To really consider an overlap, which is the standard way of interpreting an expectation value, you'd need to sandwich your transfer operators from both sides, and the fixed point in the bottom is generally not that from the top. That's kind of what I tried explaining in the note in this docstring.

Really, from an identity point of view, you're right, but it's simply not what's being calculated, and I wanted to avoid a collision of meanings behind the same "expectation value" by just giving meaning to the more correct "dominant eigenvalue"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm still confused by this, it really is what is being calculated? I think my main point here is that this seems like you are introducing a lot of extra code mostly for pedantics, which I am sympathetic to but I fail to see a lot of benefits here. In general, most of our code uses the operator type to distinguish the meanings, and since expectation_value means dot / norm, which is precisely what we want in both cases, I am not really sure why this is so confusing?

# ------------
const MultilineMPS = Multiline{<:InfiniteMPS}
#TODO: add support for finite MPS
const _MPSs = Union{InfiniteMPS, FiniteMPS}

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.

Is this AbstractMPS?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

No, because I want to leave out window MPS

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.

Why? 😉

Comment thread src/states/orthoview.jl
Comment on lines +297 to +302
return checkbounds(Bool, _lineview(psi), b)
end
_lineview(psi::ACView) = ACView(first(parent(psi.parent)))
_lineview(psi::ALView) = ALView(first(parent(psi.parent)))
_lineview(psi::ARView) = ARView(first(parent(psi.parent)))
_lineview(psi::CView) = CView(first(parent(psi.parent)))

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.

Suggested change
return checkbounds(Bool, _lineview(psi), b)
end
_lineview(psi::ACView) = ACView(first(parent(psi.parent)))
_lineview(psi::ALView) = ALView(first(parent(psi.parent)))
_lineview(psi::ARView) = ARView(first(parent(psi.parent)))
_lineview(psi::CView) = CView(first(parent(psi.parent)))
return checkbounds(Bool, parent(psi), a) &&
checkbounds(Bool, @inbounds psi[a], b)
end

Does this also work?

Comment thread src/states/orthoview.jl

# the row index is always periodic
# for infinite lines the column index is periodic as well
Base.checkbounds(::Type{Bool}, ::MultilineOrthoView{<:Multiline{<:InfiniteMPS}}, a, b) = true

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.

See comment below, which then might make this obsolete?

Comment thread src/utility/multiline.jl

VectorInterface.add!!(x::Multiline, y::Multiline, α::Number, β::Number) = add!(x, y, α, β)

# FIXME? is it intentional that a nontrivial multilinemps of normalised rows never has norm 1?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm actually wondering if this should be prod instead of sum now...

Comment thread src/utility/multiline.jl
TensorKit.storagetype(::Type{Multiline{T}}) where {T} = storagetype(T)
for ftype in (:spacetype, :sectortype, :storagetype)
@eval TensorKit.$ftype(::Type{Multiline{T}}) where {T} = $ftype(T)
@eval TensorKit.$ftype(m::Multiline) = $ftype(typeof(m))

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 somehow was under the impression that the latest versions of TensorKit already do this forwarding, is that wrong?

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.

3 participants