Program Listing for File cc.cpp

Return to documentation for file (SeQuant/domain/mbpt/models/cc.cpp)

#include <SeQuant/core/container.hpp>
#include <SeQuant/core/expr.hpp>
#include <SeQuant/core/rational.hpp>
#include <SeQuant/core/reserved.hpp>
#include <SeQuant/core/runtime.hpp>
#include <SeQuant/core/utility/macros.hpp>
#include <SeQuant/domain/mbpt/bernoulli.hpp>
#include <SeQuant/domain/mbpt/context.hpp>
#include <SeQuant/domain/mbpt/convention.hpp>
#include <SeQuant/domain/mbpt/models/cc.hpp>
#include <SeQuant/domain/mbpt/op.hpp>
#include <SeQuant/domain/mbpt/spin.hpp>
#include <SeQuant/domain/mbpt/utils.hpp>

#include <algorithm>
#include <cstdint>
#include <memory>
#include <new>
#include <stdexcept>
#include <utility>
#include <vector>

namespace {
// alias reserved labels for readability
const auto& asymm = sequant::reserved::antisymm_label();
const auto& symm = sequant::reserved::symm_label();

inline auto commutator(const sequant::ExprPtr& A, const sequant::ExprPtr& B) {
  auto result = A * B - B * A;
  sequant::non_canon_simplify(result);
  return result;
}
}  // namespace

namespace sequant::mbpt {

CC::CC(size_t n) : CC(n, Options{}) {}

CC::CC(size_t n, const Options& opts)
    : N(n),
      ansatz_(opts.ansatz),
      skip_singles_(opts.skip_singles.value_or(ansatz_ == Ansatz::oT ||
                                               ansatz_ == Ansatz::oU)),
      screen_(opts.screen),
      use_topology_(opts.use_topology),
      hbar_comm_rank_(opts.hbar_comm_rank),
      pertbar_comm_rank_(opts.pertbar_comm_rank),
      hbar_expansion_(opts.hbar_expansion) {
  if (hbar_expansion_ == HbarExpansion::Bernoulli && ansatz_ != Ansatz::U)
    throw Exception("CC: Bernoulli expansion requires the U ansatz");
  if (unitary() && !hbar_comm_rank_)
    throw Exception("CC: hbar_comm_rank is required for unitary ansatz");
  if (ansatz_ == Ansatz::oT || ansatz_ == Ansatz::oU)
    SEQUANT_ASSERT(skip_singles_,
                   "CC: skip_singles must be true for orbital-optimized "
                   "ansatz");
}

CC::Ansatz CC::ansatz() const { return ansatz_; }

bool CC::unitary() const {
  return ansatz_ == Ansatz::U || ansatz_ == Ansatz::oU;
}

std::optional<size_t> CC::hbar_comm_rank() const { return hbar_comm_rank_; }

CC::HbarExpansion CC::hbar_expansion() const { return hbar_expansion_; }

bool CC::skip_singles() const { return skip_singles_; }

bool CC::screen() const { return screen_; }

bool CC::use_topology() const { return use_topology_; }

ExprPtr CC::hbar(std::optional<size_t> truncation_rank) const {
  const auto truncation = truncation_rank.value_or(hbar_comm_rank_.value_or(4));

  if (hbar_expansion_ == HbarExpansion::Bernoulli)
    return bernoulli::hbar(N, truncation, skip_singles());

  // for a non-unitary ansatz this is the cheaper connected-product form, which
  // is only equivalent to the commutator once the caller supplies operator
  // connectivity to ref_av (see lst_options() and the @warning on hbar())
  return mbpt::lst(H(), T(N, skip_singles()), truncation, lst_options());
}

ExprPtr CC::energy(std::optional<size_t> comm_rank) const {
  // Bernoulli: the hbar expansion is at tensor level, call the tensor level
  // ref_av directly. No connectivity or screening.
  if (hbar_expansion_ == HbarExpansion::Bernoulli) {
    const auto erank = comm_rank.value_or(hbar_comm_rank_.value());
    return op::tensor::ref_av(this->hbar(erank));
  }
  // <0|H̄|0>: reference expectation value of H̄ at the requested commutator
  // truncation. No projector ⇒ this is the energy. ref_av applies the
  // connectivity (empty for unitary, default otherwise).
  return this->unitary() ? this->ref_av(this->hbar(comm_rank),
                                        mbpt::OpConnections<std::wstring>{})
                         : this->ref_av(this->hbar(comm_rank));
}

std::vector<ExprPtr> CC::t(size_t pmax, size_t pmin) const {
  pmax = (pmax == std::numeric_limits<size_t>::max() ? N : pmax);
  SEQUANT_ASSERT(pmax >= pmin, "pmax should be >= pmin");

  // Bernoulli: the hbar expansion is at tensor level, project and call the
  // tensor level ref_av directly.
  if (hbar_expansion_ == HbarExpansion::Bernoulli) {
    const auto hbar = this->hbar();
    std::vector<ExprPtr> result(pmax + 1);
    for (std::int64_t p = pmax; p >= static_cast<std::int64_t>(pmin); --p) {
      const auto projected = (p != 0) ? op::tensor::P(nₚ(p)) * hbar : hbar;
      result.at(p) = op::tensor::ref_av(projected);
    }
    return result;
  }

  // 1. construct hbar(op) in canonical form
  auto hbar = this->hbar();

  // connectivity: empty for unitary ansatz, default otherwise
  const auto connectivity = this->unitary()
                                ? mbpt::OpConnections<std::wstring>{}
                                : default_op_connections();

  // 2. project onto each manifold, screen, lower to tensor form and wick it
  std::vector<ExprPtr> result(pmax + 1);
  for (std::int64_t p = pmax; p >= static_cast<std::int64_t>(pmin); --p) {
    // 2.a. screen out terms that cannot give nonzero after projection onto
    // <p|
    std::shared_ptr<Sum>
        hbar_for_vev;  // keeps products that can produce non-zero VEV
    std::shared_ptr<Sum>
        hbar_le_p;  // keeps products that can produce excitations rank <=p

    if (screen_) {  // if operator level screening is on
      for (auto& term : *hbar) {
        SEQUANT_ASSERT(term->is<Product>() || term->is<op_t>());
        if (raises_vacuum_up_to_rank(term, p)) {
          if (!hbar_le_p)
            hbar_le_p = std::make_shared<Sum>(ExprPtrList{term});
          else
            hbar_le_p->append(term);
          if (raises_vacuum_to_rank(term, p)) {
            if (!hbar_for_vev)
              hbar_for_vev = std::make_shared<Sum>(ExprPtrList{term});
            else
              hbar_for_vev->append(term);
          }
        }
      }
      hbar = hbar_le_p;
    } else {  // no screening, use full hbar
      hbar_for_vev = hbar.is<Sum>() ? hbar.as_shared_ptr<Sum>()
                                    : std::make_shared<Sum>(hbar);
    }

    // 2.b project onto <p| (i.e., multiply by P(p) if p>0) and compute VEV
    result.at(p) = this->ref_av(p != 0 ? P(nₚ(p)) * hbar_for_vev : hbar_for_vev,
                                connectivity);
  }

  return result;
}

std::vector<ExprPtr> CC::λ() const {
  SEQUANT_ASSERT(!unitary(), "there is no need for CC::λ for unitary ansatz");

  // construct hbar
  const auto commutator_rank = hbar_comm_rank_.value_or(4);
  SEQUANT_ASSERT(commutator_rank >= 1, "CC::λ: hbar_comm_rank must be >= 1");
  auto hbar = this->hbar(commutator_rank -
                         1);  // -1 because of the connection with the projector

  auto lhbar = simplify((1 + Λ(N, skip_singles())) * hbar);

  const auto op_connect = concat(default_op_connections(), {{L"h", asymm},
                                                            {L"f", asymm},
                                                            {L"g", asymm},
                                                            {L"h", symm},
                                                            {L"f", symm},
                                                            {L"g", symm}});

  std::vector<ExprPtr> result(N + 1);

  // element 0: λ pseudoenergy, computed as the CC energy with T → Λ⁺.
  {
    // connected form; the ref_av below supplies the connectivity that makes it
    // equivalent to the commutator, here {h,f,f̃,g} with λ⁺ rather than with t
    const auto hbar_λ = mbpt::lst(H(), adjoint(Λ(N, skip_singles())),
                                  commutator_rank, lst_options());
    result.at(0) = this->ref_av(
        hbar_λ, {{L"h", L"λ⁺"}, {L"f", L"λ⁺"}, {L"f̃", L"λ⁺"}, {L"g", L"λ⁺"}});
  }

  // 2. project onto each manifold, screen, lower to tensor form and wick it
  for (auto p = N; p >= 1; --p) {
    // 2.a. screen out terms that cannot give nonzero after projection onto
    // <P|
    std::shared_ptr<Sum>
        lhbar_for_vev;  // keeps products that can produce non-zero VEV
    std::shared_ptr<Sum>
        lhbar_le_p;  // keeps products that can produce excitations rank <=p
    if (screen_) {   // if operator level screening is enabled
      for (auto& term : *lhbar) {  // pick terms from lhbar
        SEQUANT_ASSERT(term->is<Product>() || term->is<op_t>());

        if (lowers_rank_or_lower_to_vacuum(term, p)) {
          if (!lhbar_le_p)
            lhbar_le_p = std::make_shared<Sum>(ExprPtrList{term});
          else
            lhbar_le_p->append(term);
          if (lowers_rank_to_vacuum(term, p)) {
            if (!lhbar_for_vev)
              lhbar_for_vev = std::make_shared<Sum>(ExprPtrList{term});
            else
              lhbar_for_vev->append(term);
          }
        }
      }
      lhbar = lhbar_le_p;
    } else {  // no screening
      lhbar_for_vev = lhbar.is<Sum>() ? lhbar.as_shared_ptr<Sum>()
                                      : std::make_shared<Sum>(lhbar);
    }

    // 2.b multiply by adjoint of P(p) (i.e., P(-p)) on the right side and
    // compute VEV
    result.at(p) = this->ref_av(lhbar_for_vev * P(nₚ(-p)), op_connect);
  }
  return result;
}

ExprPtr CC::rdm(size_t rank, std::optional<size_t> comm_rank) const {
  SEQUANT_ASSERT(hbar_expansion_ != HbarExpansion::Bernoulli,
                 "CC::rdm: the Bernoulli expansion is not supported yet");

  // 1. replacement operator {ã^{p_1..p_r}_{p_{r+1}..p_{2r}}} (see op::ã); its
  // indices are free, so they become the free indices of γ.
  auto replacer = op::ã(rank);

  // 2. similarity transform e^{-σ} ã e^{σ} (σ = T for traditional and σ = T−T⁺
  // for unitary). Traditional ansatz: the expansion terminates exactly, so the
  // default is the largest number of T's that can survive the VEV below: each T
  // must consume at least one of ã's 2r legs (k <= 2r), and T's 2k
  // quasi-creators must be absorbed by ã's 2r plus Λ's 2N legs (k <= r + N).
  // Unitary ansatz: T⁺ contracts with T, the expansion never terminates, so
  // there is no safe default; use the engine's hbar_comm_rank (the ctor
  // guarantees it is set). The traditional branch takes lst_options()'s
  // connected-product form; the {ã,t} connectivity handed to ref_av below is
  // what makes it equivalent to the explicit commutator.
  const auto commutator_rank = comm_rank.value_or(
      unitary() ? hbar_comm_rank_.value() : std::min(2 * rank, rank + N));
  auto bar =
      mbpt::lst(replacer, T(N, skip_singles()), commutator_rank, lst_options());

  // 3. reference expectation value with the ansatz's left wavefunction:
  // γ = <0|(1+Λ) e^{-σ} ã e^{σ}|0>; the unitary ansatz drops Λ.
  auto expr = unitary() ? bar : simplify((1 + Λ(N, skip_singles())) * bar);
  const auto connect =
      unitary() ? mbpt::OpConnections<std::wstring>{}
                : mbpt::OpConnections<std::wstring>{{L"ã", L"t"}, {L"t", L"ã"}};
  auto gamma = this->ref_av(expr, connect);

  return gamma;
}

std::vector<ExprPtr> CC::(size_t rank, size_t order,
                            std::optional<size_t> nbatch) const {
  SEQUANT_ASSERT(order == 1,
                 "sequant::mbpt::CC::tʼ(): only first-order perturbation is "
                 "supported now");
  SEQUANT_ASSERT(rank == 1,
                 "sequant::mbpt::CC::tʼ(): only one-body perturbation "
                 "operator is supported now");
  if (unitary())
    SEQUANT_ASSERT(pertbar_comm_rank_,
                   "pertbar_comm_rank must be specified for unitary ansatz");
  SEQUANT_ASSERT(hbar_expansion_ != HbarExpansion::Bernoulli,
                 "CC::tʼ: the Bernoulli expansion is not supported yet");

  // construct h1_bar
  // truncate h1_bar at rank 2 for one-body perturbation operator and at rank 4
  // for two-body perturbation operator; unless specified otherwise
  const auto h1_truncate_default = rank == 1 ? 2 : 4;
  const auto h1_truncate_at = pertbar_comm_rank_.value_or(h1_truncate_default);
  const auto h1_bar =
      mbpt::lst((rank, {.order = order, .nbatch = nbatch}),
                T(N, skip_singles()), h1_truncate_at, lst_options());

  // construct [hbar, Tʼ(1)]
  const auto hbar_truncate_at = hbar_comm_rank_.value_or(
      3);  // notice 3 instead of 4 here, this is because of the commutator with
           // T'(1). In case 4 is used, it will generate more terms but they
           // will not contribute.
  const auto hbar = this->hbar(hbar_truncate_at);

  ExprPtr hbar_pert;
  if (unitary()) {
    // for unitary ansatz, we need to compute the commutator [hbar, Tʼ],
    // otherwise just hbar * Tʼ is sufficient because ref_av uses connectivity
    hbar_pert = commutator(hbar, (N, {.order = order, .nbatch = nbatch}));
  } else {
    hbar_pert = hbar * (N, {.order = order, .nbatch = nbatch});
  }

  // [Eq. 34, WIREs Comput Mol Sci. 2019; 9:e1406]
  const auto expr = simplify(h1_bar + hbar_pert);

  // connectivity: empty for unitary ansatz, build otherwise
  OpConnections<std::wstring> op_connect;
  if (!this->unitary()) {
    // connect t and t1 with {h,f,g}
    // connect h1 with t
    op_connect =
        concat(default_op_connections(),
               {{L"h", L"t¹"}, {L"f", L"t¹"}, {L"g", L"t¹"}, {L"h¹", L"t"}});
  }

  std::vector<ExprPtr> result(N + 1);
  for (auto p = N; p >= 1; --p) {
    const auto freq_term =
        L"ω" * P(nₚ(p)) * op::(p, {.order = order, .nbatch = nbatch});
    result.at(p) =
        this->ref_av(P(nₚ(p)) * expr, op_connect) - this->ref_av(freq_term);
  }
  return result;
}

std::vector<ExprPtr> CC::λʼ(size_t rank, size_t order,
                            std::optional<size_t> nbatch) const {
  SEQUANT_ASSERT(order == 1,
                 "sequant::mbpt::CC::λʼ(): only first-order perturbation is "
                 "supported now");
  SEQUANT_ASSERT(rank == 1,
                 "sequant::mbpt::CC::λʼ(): only one-body perturbation "
                 "operator is supported now");
  SEQUANT_ASSERT(!unitary(), "there is no need for CC::λʼ for unitary ansatz");
  SEQUANT_ASSERT(ansatz_ == Ansatz::T,
                 "CC::λʼ: only traditional ansatz is supported");

  // construct hbar
  const auto hbar = this->hbar();

  // construct h1_bar
  // truncate h1_bar at rank 2 for one-body perturbation operator and at rank 4
  // for two-body perturbation operator; unless specified otherwise
  const auto h1_truncate_at = (rank == 1) ? pertbar_comm_rank_.value_or(2)
                                          : pertbar_comm_rank_.value_or(4);
  // connected form (this path is non-unitary, see the assert above); the
  // op_connect built below is a superset of default_op_connections() and so
  // supplies the connectivity that makes it equivalent to the commutator
  const auto h1_bar =
      mbpt::lst((rank, {.order = order, .nbatch = nbatch}),
                T(N, skip_singles()), h1_truncate_at, lst_options());

  // construct [hbar, T(1)]
  const auto hbar_pert =
      this->hbar(3) * (N, {.order = order, .nbatch = nbatch});

  // [Eq. 35, WIREs Comput Mol Sci. 2019; 9:e1406]
  const auto expr = simplify((1 + Λ(N, skip_singles())) * (h1_bar + hbar_pert) +
                             Λʼ(N, {.order = order, .nbatch = nbatch}) * hbar);

  // connectivity:
  // t and t1 with {h,f,g}
  // projectors with {h,f,g}
  // h1 with t
  // h1 with projectors
  const auto op_connect = concat(default_op_connections(), {{L"h", L"t¹"},
                                                            {L"f", L"t¹"},
                                                            {L"g", L"t¹"},
                                                            {L"h¹", L"t"},
                                                            {L"h", asymm},
                                                            {L"f", asymm},
                                                            {L"g", asymm},
                                                            {L"h", symm},
                                                            {L"f", symm},
                                                            {L"g", symm},
                                                            {L"h¹", asymm},
                                                            {L"h¹", symm}});

  std::vector<ExprPtr> result(N + 1);
  for (auto p = N; p >= 1; --p) {
    const auto freq_term =
        L"ω" * op::λʼ(p, {.order = order, .nbatch = nbatch}) * P(nₚ(-p));
    result.at(p) =
        this->ref_av(expr * P(nₚ(-p)), op_connect) + this->ref_av(freq_term);
  }
  return result;
}

namespace {
// EOM eigenvector operators R and L use SquareRoot normalization
constexpr Normalization eom_norm = Normalization::SquareRoot;

container::svector<std::pair<std::int64_t, std::int64_t>> eom_manifolds(nₚ np,
                                                                        nₕ nh) {
  container::svector<std::pair<std::int64_t, std::int64_t>> manifolds;
  for (std::int64_t rp = np, rh = nh; rp >= 0 && rh >= 0; --rp, --rh) {
    if (rp == 0 && rh == 0) break;
    manifolds.emplace_back(rp, rh);
    if (rp == 0 || rh == 0) break;
  }
  std::ranges::reverse(manifolds);
  return manifolds;
}
}  // namespace

// Per-block-truncated EOM sigma equations. For the qUCCSD ranks see
// 10.1063/5.0062090 Sec. II C, Eqs. (29)-(48); for IP/EA,
// 10.1021/acs.jctc.5c01991 Fig. 1.
//
// Eq. (10) splits the single physical E_gr from the normal-ordered components
// that build the blocks of Eq. (7). Each cumulative H̄^(k) used below still
// carries its rank-dependent scalar part, so remove that same scalar on the
// diagonal before assembling the separately truncated components.
std::vector<ExprPtr> CC::eom_r_blocked(
    nₚ np, nₕ nh, const std::vector<size_t>& block_ranks) const {
  if (!unitary())
    throw Exception("CC::eom_r: block_ranks require a unitary ansatz");

  const auto manifolds = eom_manifolds(np, nh);
  const auto K = manifolds.size();
  // `block_ranks` is read at i * K + j, so the ascending order above is what
  // makes `{2,1,1,0}` mean SS, SD, DS, DD; empty means uniform hbar_comm_rank
  const std::vector<size_t> ranks =
      block_ranks.empty() ? std::vector<size_t>(K * K, hbar_comm_rank().value())
                          : block_ranks;
  if (ranks.size() != K * K)
    throw Exception(
        "CC::eom_r: block_ranks must be a K x K row-major matrix, "
        "K = number of projection manifolds");

  // Bernoulli H̄ is tensor-level, BCH H̄ operator-level; the bra/ket/vev trio
  // below must match it. Connectivity is empty either way, as everywhere on the
  // unitary path; only the operator-level vev forwards screen/use_topology.
  const bool tensor_level = hbar_expansion_ == HbarExpansion::Bernoulli;

  // One H̄ per distinct truncation order, reduced to its R part (Bernoulli only;
  // BCH H̄ is operator-level and has no N/R split). The N part is the amplitude
  // residual <Φl|H̄|Φ0>, which Eq. (6) zeroes only at the amplitude rank and
  // Eqs. (41)-(47) carry nowhere. It shifts the manifold rank, so removing it
  // leaves diagonal blocks untouched.
  container::map<size_t, ExprPtr> hbars;
  for (const auto k : ranks) {
    auto [it, fresh] = hbars.try_emplace(k);
    if (!fresh) continue;  // deriving H̄ twice for one rank is not cheap
    it->second = hbar(k);
    if (tensor_level)
      it->second =
          bernoulli::detail::R_part(it->second, N, skip_singles() ? 2 : 1);
  }
  auto bra_of = [tensor_level](std::int64_t p, std::int64_t h) {
    return tensor_level ? op::tensor::δl(nₚ(p), nₕ(h)) : op::δl(nₚ(p), nₕ(h));
  };
  auto ket_of = [tensor_level](std::int64_t p, std::int64_t h) {
    return tensor_level ? op::tensor::r(nₚ(p), nₕ(h), eom_norm)
                        : op::r(nₚ(p), nₕ(h), eom_norm);
  };
  auto vev = [tensor_level, this](const ExprPtr& e) {
    return tensor_level ? op::tensor::ref_av(e)
                        : op::ref_av(e, {.connect = {},
                                         .screen = screen_,
                                         .use_topology = use_topology_});
  };

  using std::min;
  std::vector<ExprPtr> result(min(np, nh) + 1);
  for (size_t i = 0; i < K; ++i) {
    const auto [bp, bh] = manifolds[i];
    const auto bra = bra_of(bp, bh);
    auto acc = std::make_shared<Sum>();
    for (size_t j = 0; j < K; ++j) {
      const auto [kp, kh] = manifolds[j];
      const auto& hbar_ij = hbars.at(ranks.at(i * K + j));
      const auto ket = ket_of(kp, kh);
      acc->append(vev(bra * hbar_ij * ket));
      // Remove the scalar part of this block's temporary H̄^(k_ii). This is
      // not a block-dependent physical E_gr: it leaves the normal-ordered
      // coefficients selected for this block in Eq. (10). Written as
      // <i|r_i H̄|0> so Wick keeps its summed indices disjoint from the block's
      // external ones.
      if (i == j) acc->append(ex<Constant>(-1) * vev(bra * ket * hbar_ij));
    }
    result.at(static_cast<size_t>(min(bp, bh))) = simplify(ExprPtr{acc});
  }
  return result;
}

std::vector<ExprPtr> CC::eom_r(nₚ np, nₕ nh,
                               const std::vector<size_t>& block_ranks) const {
  SEQUANT_ASSERT(np > 0 || nh > 0, "Unsupported excitation order");
  if (np != nh)
    SEQUANT_ASSERT(
        get_default_context().spbasis() != SPBasis::Spinfree,
        "spin-free basis does not yet support non particle-conserving cases");

  // Bernoulli always takes the blocked path: the uniform one below commutes H̄
  // with an operator-level R, which a tensor-level H̄ cannot take part in.
  if (!block_ranks.empty() || hbar_expansion_ == HbarExpansion::Bernoulli)
    return eom_r_blocked(np, nh, block_ranks);

  // construct hbar
  const auto hbar = this->hbar();

  // construct [hbar, R]
  ExprPtr hbar_R;
  // for unitary ansatz, we need to compute the commutator [hbar, R], otherwise
  // just hbar * R is sufficient because ref_av uses connectivity
  if (this->unitary()) {
    hbar_R = commutator(hbar, R(np, nh, eom_norm));
  } else {
    hbar_R = hbar * R(np, nh, eom_norm);
  }

  // connectivity: empty for unitary ansatz, build otherwise
  OpConnections<std::wstring> op_connect;
  if (!this->unitary()) {
    // default connections + connect R with {h,f,g}
    op_connect = concat(default_op_connections(),
                        {{L"h", L"R"}, {L"f", L"R"}, {L"g", L"R"}});
  }
  using std::min;
  std::vector<ExprPtr> result(min(np, nh) + 1);  // for EE element 0 stays null
  // project with <rp, rh| (δl for consistent normalization) and compute VEV
  for (const auto& [rp, rh] : eom_manifolds(np, nh))
    result.at(min(rp, rh)) =
        this->ref_av(δl(nₚ(rp), nₕ(rh)) * hbar_R, op_connect);

  return result;
}

std::vector<ExprPtr> CC::eom_l(nₚ np, nₕ nh) const {
  SEQUANT_ASSERT(!unitary(),
                 "there is no need for CC::eom_l for unitary ansatz");
  SEQUANT_ASSERT(np > 0 || nh > 0, "Unsupported excitation order");

  if (np != nh)
    SEQUANT_ASSERT(
        get_default_context().spbasis() != SPBasis::Spinfree &&
        "spin-free basis does not support non particle-conserving cases");

  // construct hbar
  const auto hbar = this->hbar();

  // L * hbar
  const auto L_hbar = L(np, nh, eom_norm) * hbar;

  // connectivity:
  // default connections + connect H with projectors
  const auto op_connect = concat(default_op_connections(), {{L"h", asymm},
                                                            {L"f", asymm},
                                                            {L"g", asymm},
                                                            {L"h", symm},
                                                            {L"f", symm},
                                                            {L"g", symm}});

  using std::min;
  std::vector<ExprPtr> result(min(np, nh) + 1);  // for EE element 0 stays null
  // right project with |rp,rh> (i.e., multiply δr(rp, rh)) and compute VEV
  for (const auto& [rp, rh] : eom_manifolds(np, nh))
    result.at(min(rp, rh)) =
        this->ref_av(L_hbar * δr(nₚ(rp), nₕ(rh)), op_connect);

  return result;
}
}  // namespace sequant::mbpt