Program Listing for File bernoulli.cpp

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

#include <SeQuant/domain/mbpt/bernoulli.hpp>

#include <SeQuant/core/context.hpp>
#include <SeQuant/core/index.hpp>
#include <SeQuant/core/index_space_registry.hpp>
#include <SeQuant/core/op.hpp>
#include <SeQuant/core/rational.hpp>
#include <SeQuant/core/utility/exception.hpp>
#include <SeQuant/core/utility/expr.hpp>
#include <SeQuant/core/utility/indices.hpp>
#include <SeQuant/core/utility/macros.hpp>
#include <SeQuant/core/wick.hpp>
#include <SeQuant/domain/mbpt/context.hpp>
#include <SeQuant/domain/mbpt/op.hpp>

#include <range/v3/algorithm/all_of.hpp>
#include <range/v3/algorithm/any_of.hpp>
#include <range/v3/algorithm/none_of.hpp>
#include <range/v3/range/primitives.hpp>

#include <algorithm>
#include <string>
#include <utility>

// Bernoulli expansion of the unitary-CC similarity-transformed Hamiltonian
// H̄ = e^{−σ} H e^{σ}, σ = T − T† (anti-Hermitian). For UCC the plain BCH
// series does not terminate, because σ mixes excitation and de-excitation.
// This file implements the expansion of 10.1063/1.5030344 for finite
// truncation. H splits as F (Fock) + V (fluctuation potential);
// every operator O splits into O_N (its pure excitation/de-excitation part) and
// O_R = O − O_N.
//
// Equation numbers below are all from 10.1063/1.5030344, Sec. II B.
//
// The F-cancellation: at an HF reference F has no occupied-virtual block
// (Brillouin, Eq. (32)), so H̄² and higher contain no F (stated just below
// Eq. (50)). H̄⁰ and H̄¹ do. They use the general one-body operator, so terms
// like f{a_1;i_1} stay in the derived equations; they are zero only once HF
// Fock elements are substituted for them.

namespace {

const sequant::NormalOperator<sequant::Statistics::FermiDirac>* find_nop(
    const sequant::ExprPtr& term) {
  using namespace sequant;
  if (term.is<NormalOperator<Statistics::FermiDirac>>())
    return &term.as<NormalOperator<Statistics::FermiDirac>>();

  if (term.is<Product>()) {
    const NormalOperator<Statistics::FermiDirac>* found = nullptr;
    for (const auto& f : term.as<Product>().factors())
      if (f.is<NormalOperator<Statistics::FermiDirac>>()) {
        if (found)
          throw Exception("find_nop: term contains multiple NormalOperators");
        found = &f.as<NormalOperator<Statistics::FermiDirac>>();
      }

    return found;
  }
  return nullptr;
}

bool is_N_term(const sequant::ExprPtr& term, std::size_t cutoff,
               std::size_t min_rank) {
  using namespace sequant;
  auto isr = get_default_context().index_space_registry();

  const auto* nop = find_nop(term);
  if (!nop) return false;

  const auto ncre = ranges::distance(nop->creators());
  const auto nann = ranges::distance(nop->annihilators());
  const auto rank = static_cast<std::size_t>(std::max(ncre, nann));
  if (rank > cutoff || rank < min_rank) return false;

  auto all_unocc = [&](auto&& ops) {
    return ranges::all_of(ops, [&](const auto& o) {
      return isr->is_pure_unoccupied(o.index().space());
    });
  };
  auto all_occ = [&](auto&& ops) {
    return ranges::all_of(ops, [&](const auto& o) {
      return isr->is_pure_occupied(o.index().space());
    });
  };

  const bool pure_exc =
      all_unocc(nop->creators()) && all_occ(nop->annihilators());
  const bool pure_deexc =
      all_occ(nop->creators()) && all_unocc(nop->annihilators());

  return pure_exc || pure_deexc;
}

}  // namespace

namespace sequant::mbpt::bernoulli {

namespace detail {

ExprPtr wick_reduce(const ExprPtr& expr_in) {
  auto expr = expr_in->clone();
  simplify(expr);
  FWickTheorem wick{expr};
  // use_topology is on by default but only correct when every operator is
  // contracted; here we want partial contractions, and it would silently
  // rescale terms.
  wick.use_topology(false).full_contractions(false);
  auto result = wick.compute(/*count_only=*/false,
                             /*skip_input_canonicalization=*/true);
  simplify(result);
  return result;
}

ExprPtr wick_commutator(const ExprPtr& A, const ExprPtr& B) {
  // A and B are built independently, so shared labels (both a block-resolved
  // part and sigma carry a/i) would fuse two independent summations in A*B.
  // Reindex B to fresh temporaries; canonicalization restores tidy labels.
  container::map<Index, Index> repl;
  for (const auto& idx : get_used_indices(B))
    repl.emplace(idx, Index::make_tmp_index(idx.space()));
  const auto Bd = repl.empty() ? B : transform_expr(B, repl);
  return wick_reduce(simplify(A * Bd - Bd * A));
}

namespace {

ExprPtr expand_to_blocks_reduced(const ExprPtr& expr) {
  auto isr = get_default_context().index_space_registry();
  const auto& bases = isr->base_spaces();

  auto is_base_space = [&](const IndexSpace& sp) {
    return ranges::any_of(bases, [&](const auto& b) { return b == sp; });
  };

  // Split each general index over the hole and particle base spaces only; see
  // the @warning on hbar for the others. 2-way per index instead of 4-way,
  // which compounds across the nested commutators.
  const auto& hole_t = isr->hole_space();
  const auto& particle_t = isr->particle_space();
  auto physical = [&](const IndexSpace& b) {
    return hole_t.includes(b.type()) || particle_t.includes(b.type());
  };

  auto expand_term = [&](const ExprPtr& term) -> ExprPtr {
    // collect the residual NormalOperator's distinct general (non-base) indices
    const auto* nop = find_nop(term);
    if (!nop)
      return term->clone();  // pure scalar/contraction: nothing to split
    container::svector<Index> gens;
    for (const auto& op : nop->creann()) {
      if (!is_base_space(op.index().space()) &&
          ranges::none_of(gens, [&](const auto& g) { return g == op.index(); }))
        gens.push_back(op.index());
    }
    if (gens.empty()) return term->clone();
    // candidate base spaces per general index: a hole/particle base b qualifies
    // when g's type bits include b's and their quantum numbers match.
    container::svector<container::svector<IndexSpace>> choices;
    for (const auto& g : gens) {
      container::svector<IndexSpace> c;
      for (const auto& b : bases)
        if (physical(b) && b.qns() == g.space().qns() &&
            g.space().type().includes(b.type()))
          c.push_back(b);
      SEQUANT_ASSERT(!c.empty(),
                     "bernoulli: general index spans no hole/particle base "
                     "space with matching quantum numbers");
      choices.push_back(std::move(c));
    }
    // cartesian product of assignments => sum of transformed terms. Sum::append
    // is linear; operator+ would deep-copy the accumulated Sum on every call.
    auto sum = std::make_shared<Sum>();
    container::svector<std::size_t> idx(gens.size(), 0);
    for (;;) {
      container::map<Index, Index> repl;
      for (std::size_t k = 0; k < gens.size(); ++k)
        // fresh ordinal: reusing gens[k]'s would collide with a definite index
        // of the same base space already in the term, e.g. from an amplitude
        repl.emplace(gens[k], Index::make_tmp_index(choices[k][idx[k]]));
      sum->append(transform_expr(term, repl));
      // increment mixed-radix counter over the assignments
      std::size_t k = 0;
      for (; k < gens.size(); ++k) {
        if (++idx[k] < choices[k].size()) break;
        idx[k] = 0;
      }
      if (k == gens.size()) break;
    }
    return ExprPtr{sum};
  };

  ExprPtr out;
  if (expr.is<Sum>()) {
    out = transform_sum_expr(expr.as<Sum>().summands(), expand_term);
  } else {
    out = expand_term(expr);
  }
  simplify(out);
  return out;
}

ExprPtr keep_N_terms(const ExprPtr& bx, std::size_t cutoff,
                     std::size_t min_rank) {
  if (bx.is<Sum>()) {
    auto out = std::make_shared<Sum>();
    for (const auto& t : bx.as<Sum>())
      if (is_N_term(t, cutoff, min_rank)) out->append(t);
    return out->empty() ? ex<Constant>(0) : simplify(ExprPtr{out});
  }
  return is_N_term(bx, cutoff, min_rank) ? bx : ex<Constant>(0);
}

ExprPtr N_part_reduced(const ExprPtr& reduced, std::size_t cutoff,
                       std::size_t min_rank) {
  return keep_N_terms(expand_to_blocks_reduced(reduced), cutoff, min_rank);
}

ExprPtr R_part_reduced(const ExprPtr& reduced, std::size_t cutoff,
                       std::size_t min_rank) {
  return simplify(reduced - N_part_reduced(reduced, cutoff, min_rank));
}

}  // namespace

ExprPtr expand_to_blocks(const ExprPtr& expr_in) {
  return expand_to_blocks_reduced(wick_reduce(expr_in));
}

ExprPtr N_part(const ExprPtr& expr, std::size_t cutoff, std::size_t min_rank) {
  return keep_N_terms(expand_to_blocks(expr), cutoff, min_rank);
}

// For the supported single-reference projections, expand_to_blocks preserves
// the projected expression. Subtracting its block-resolved N part from the
// compact expression therefore gives the same projected remainder with fewer
// terms.
ExprPtr R_part(const ExprPtr& expr, std::size_t cutoff, std::size_t min_rank) {
  auto reduced = wick_reduce(expr);
  return R_part_reduced(reduced, cutoff, min_rank);
}

}  // namespace detail

// Each H̄^k below transcribes its equation. A subscript R/N means "take that
// part of the commutator before the next nesting".
ExprPtr hbar(std::size_t N, std::size_t rank, bool skip1) {
  if (get_default_mbpt_context().csv() == CSV::Yes)
    throw Exception("bernoulli::hbar: CSV is not supported");
  if (rank > 4)
    throw Exception("bernoulli::hbar: only ranks [0,4] are implemented");

  using namespace detail;
  // σ carries ranks [min_rank, cutoff]; V̄_N = 0 holds over exactly that range
  const auto cutoff = N;
  const std::size_t min_rank = skip1 ? 2 : 1;
  const auto F = op::tensor::F();
  const auto V = op::tensor::h(2);
  const auto T = op::tensor::T(N, skip1);
  const auto sigma = simplify(T - adjoint(T));

  // Applies one partition tag; 'A' is no filter. `reduced` skips the
  // wick_reduce inside N_part/R_part, which commutator output has had already.
  auto part = [&](char tag, const ExprPtr& e, bool reduced) -> ExprPtr {
    SEQUANT_ASSERT(tag == 'A' || tag == 'N' || tag == 'R',
                   "bernoulli::hbar: partition tag must be one of A, N, R");
    if (tag == 'A') return e;
    if (tag == 'N')
      return reduced ? N_part_reduced(e, cutoff, min_rank)
                     : N_part(e, cutoff, min_rank);
    return reduced ? R_part_reduced(e, cutoff, min_rank)
                   : R_part(e, cutoff, min_rank);
  };

  // Every term of H̄^k is a nested commutator [[..[V_{p0},σ]_{f0}..],σ]_{f_k},
  // one partition tag per level. nest memoizes each prefix (key = p0 + tags so
  // far); prefixes repeat within a rank and across ranks.
  container::map<std::string, ExprPtr> memo;
  auto nest = [&](char p0, const char* f) -> ExprPtr {
    // grow `key` in place rather than deriving it from the memo iterator:
    // container::map is a flat_map, whose insertions invalidate iterators
    std::string key{p0};
    auto it = memo.find(key);
    if (it == memo.end())
      it = memo.emplace(key, part(p0, V, /*reduced=*/false)).first;
    ExprPtr cur = it->second;
    for (int i = 0; f[i] != '\0'; ++i) {
      key += f[i];
      it = memo.find(key);
      if (it == memo.end()) {
        auto cx = wick_commutator(cur, sigma);
        it = memo.emplace(key, part(f[i], cx, /*reduced=*/true)).first;
      }
      cur = it->second;
    }
    return cur;
  };

  HashingAccumulator acc;
  auto add = [&acc](rational num, const ExprPtr& e) {
    if (e.is<Sum>()) {
      for (const auto& term : e.as<Sum>()) {
        auto scaled = ex<Product>(ExprPtrList{term});
        scaled.as<Product>().scale(num);
        acc.append(std::move(scaled), /*flatten=*/false);
      }
    } else {
      auto scaled = ex<Product>(ExprPtrList{e});
      scaled.as<Product>().scale(num);
      acc.append(std::move(scaled), /*flatten=*/false);
    }
  };

  add(1, simplify(F + V));  // H̄⁰ = F + V   [Eq. (46)]
  if (rank >= 1) {
    // H̄¹ = [F,σ] + ½[V,σ] + ½[V_R,σ]   [Eq. (47)]. The only F commutator in H̄;
    // H̄⁰ carries the bare F. See the F-cancellation at the top of this file.
    add(1, wick_commutator(F, sigma));
    add({1, 2}, nest('A', "A"));
    add({1, 2}, nest('R', "A"));
  }
  if (rank >= 2) {
    // H̄² = 1/12[[V_N,σ],σ] + ¼[[V,σ]_R,σ] + ¼[[V_R,σ]_R,σ]   [Eq. (48)]
    add({1, 12}, nest('N', "AA"));
    add({1, 4}, nest('A', "RA"));
    add({1, 4}, nest('R', "RA"));
  }
  if (rank >= 3) {
    // H̄³ = 1/24[[[V_N,σ],σ]_R,σ] + ⅛[[[V,σ]_R,σ]_R,σ] + ⅛[[[V_R,σ]_R,σ]_R,σ]
    //       − 1/24[[[V,σ]_R,σ],σ] − 1/24[[[V_R,σ]_R,σ],σ]   [Eq. (49)]
    add({1, 24}, nest('N', "ARA"));
    add({1, 8}, nest('A', "RRA"));
    add({1, 8}, nest('R', "RRA"));
    add({-1, 24}, nest('A', "RAA"));
    add({-1, 24}, nest('R', "RAA"));
  }
  if (rank >= 4) {
    // H̄⁴ = Eq. (50), nine terms, no F. Listed in the paper's order; the
    // outermost tag is always A.
    add({1, 16}, nest('R', "RRRA"));
    add({1, 16}, nest('A', "RRRA"));
    add({1, 48}, nest('N', "ARRA"));
    add({-1, 48}, nest('A', "RARA"));
    add({-1, 48}, nest('R', "RARA"));
    add({-1, 144}, nest('N', "ARAA"));
    add({-1, 48}, nest('A', "RRAA"));
    add({-1, 48}, nest('R', "RRAA"));
    add({-1, 720}, nest('N', "AAAA"));
  }
  auto result = acc.make_expr();
  return simplify(result);
}

}  // namespace sequant::mbpt::bernoulli