diff --git a/CMakeLists.txt b/CMakeLists.txt index 29643264fc..fce07df7b1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -440,6 +440,8 @@ set(SeQuant_eval_src SeQuant/core/eval/eval_node_compare.hpp SeQuant/core/eval/result.cpp SeQuant/core/eval/result.hpp + SeQuant/core/eval/slot_symmetry.cpp + SeQuant/core/eval/slot_symmetry.hpp SeQuant/core/eval/fwd.hpp ) diff --git a/SeQuant/core/eval/eval_expr.cpp b/SeQuant/core/eval/eval_expr.cpp index 16b8c70797..dd792b0a3c 100644 --- a/SeQuant/core/eval/eval_expr.cpp +++ b/SeQuant/core/eval/eval_expr.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -136,7 +137,8 @@ EvalExpr::index_vector const& EvalExpr::canon_indices() const noexcept { EvalExpr::EvalExpr(Tensor const& tnsr) : op_type_{std::nullopt}, result_type_{ResultType::Tensor}, - expr_{tnsr.clone()} { + expr_{tnsr.clone()}, + slot_symmetry_{from_leaf_tensor(tnsr)} { SEQUANT_ASSERT(!tnsr.indices().empty()); if (is_tot(tnsr)) { ExprPtrList tlist{expr_}; @@ -290,6 +292,10 @@ std::shared_ptr EvalExpr::copy_connectivity_graph() return connectivity_; } +SlotSymmetry const& EvalExpr::slot_symmetry() const noexcept { + return slot_symmetry_; +} + namespace { /// @@ -423,6 +429,9 @@ EvalExprNode binarize(Tensor const& t) { 1, // h, // nullptr}; + // Out-of-band: adjoint swaps bra_groups <-> ket_groups and preserves + // column_groups (real-field; complex conjugation deferred to Phase 1). + EvalOpSetter{}.set_slot_symmetry(adj, adjoint(bare_leaf->slot_symmetry())); return EvalExprNode{std::move(adj), std::move(bare_leaf), std::move(sentinel)}; } @@ -451,12 +460,13 @@ EvalExprNode binarize(Sum const& sum, IndexSet const& uncontract, auto make_sum = [i = 0, // hs = imed_hashes(hvals), // - all_tensors, &opts](EvalExpr const& left, - EvalExpr const&) mutable -> EvalExpr { + all_tensors, + &opts](EvalExpr const& left, + EvalExpr const& right) mutable -> EvalExpr { auto h = ranges::at(hs, ++i); if (all_tensors) { auto const& t = left.as_tensor(); - return { + EvalExpr res{ EvalOp::Sum, // ResultType::Tensor, // detail::make_tensor_wo_symmetries(opts, bra(t.bra()), ket(t.ket()), @@ -465,6 +475,17 @@ EvalExprNode binarize(Sum const& sum, IndexSet const& uncontract, 1, // h, // nullptr}; + // intersect compares groups by integer slot position; this is valid + // because all summands share the same external-slot layout (the Sum + // result is built from left.canon_indices()). Only propagate the + // descriptor when the result preserves the operand's bra/ket layout + // (not the case under merge_indices mode, I1 fix; I3 invariant). + if (res.as_tensor().bra_rank() == left.as_tensor().bra_rank() && + res.as_tensor().ket_rank() == left.as_tensor().ket_rank()) + EvalOpSetter{}.set_slot_symmetry( + res, intersect(left.slot_symmetry(), right.slot_symmetry())); + // else: leave slot_symmetry empty (default-constructed). + return res; } else { return {EvalOp::Sum, // ResultType::Scalar, // @@ -525,7 +546,7 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, // scalar * tensor or tensor * scalar auto const& tl = left->is_tensor() ? left : right; auto const& t = tl->as_tensor(); - return { + EvalExpr res{ EvalOp::Product, // ResultType::Tensor, // detail::make_tensor_wo_symmetries(opts, bra(t.bra()), ket(t.ket()), @@ -534,6 +555,15 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, 1, // h, nullptr}; + // A scalar factor cannot break permutational symmetry: pass through the + // tensor operand's descriptor only when the result preserves the + // operand's bra/ket layout (not the case under merge_indices mode, where + // all indices collapse into aux and the slot positions are meaningless). + if (res.as_tensor().bra_rank() == t.bra_rank() && + res.as_tensor().ket_rank() == t.ket_rank()) + EvalOpSetter{}.set_slot_symmetry(res, tl->slot_symmetry()); + // else: leave slot_symmetry empty (default-constructed). + return res; } else { // tensor * tensor container::svector subfacs; @@ -576,15 +606,20 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, h, std::move(canon.graph)}; } else { - return {EvalOp::Product, // - ResultType::Tensor, // - detail::make_tensor_wo_symmetries(opts, bra(target_indices.bra), - ket(target_indices.ket), - aux(target_indices.aux)), - canon.get_indices(), // - canon.phase, // - h, - std::move(canon.graph)}; + EvalExpr res{EvalOp::Product, // + ResultType::Tensor, // + detail::make_tensor_wo_symmetries( + opts, bra(target_indices.bra), ket(target_indices.ket), + aux(target_indices.aux)), + canon.get_indices(), // + canon.phase, // + h, + std::move(canon.graph)}; + // Out-of-band slot-symmetry deduction (does not affect the result + // tensor, its hash, canon indices, or graph above). + EvalOpSetter{}.set_slot_symmetry( + res, deduce_slot_symmetry(*left, *right, res.as_tensor())); + return res; } } }; @@ -611,6 +646,19 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, h, // nullptr}; + // The trailing scalar factor preserves the tensor sub-result's slot layout + // only when the result bra/ket match the sub-result (make_tensor above + // keeps the index order under the default mode; under merge_indices all + // slots collapse into aux so the positions would be meaningless, I1 fix). + if (left->is_tensor()) { + auto const& left_t = left->as_tensor(); + auto const& result_t = result.as_tensor(); + if (result_t.bra_rank() == left_t.bra_rank() && + result_t.ket_rank() == left_t.ket_rank()) + EvalOpSetter{}.set_slot_symmetry(result, left->slot_symmetry()); + // else: leave slot_symmetry empty (default-constructed). + } + return EvalExprNode{std::move(result), std::move(left), std::move(right)}; } } diff --git a/SeQuant/core/eval/eval_expr.hpp b/SeQuant/core/eval/eval_expr.hpp index 7063980195..09c3b5c975 100644 --- a/SeQuant/core/eval/eval_expr.hpp +++ b/SeQuant/core/eval/eval_expr.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -270,6 +271,15 @@ class EvalExpr { [[nodiscard]] std::shared_ptr copy_connectivity_graph() const noexcept; + /// + /// \return The out-of-band permutational-symmetry descriptor for this node's + /// result tensor. Default-constructed (empty) until a later + /// deduction pass writes it. This field is intentionally NOT + /// referenced by any hashing, canonicalization, bliss-graph, or + /// export code path. + /// + [[nodiscard]] SlotSymmetry const& slot_symmetry() const noexcept; + protected: std::optional op_type_ = std::nullopt; @@ -284,11 +294,20 @@ class EvalExpr { size_t hash_value_; std::shared_ptr connectivity_; + + /// Out-of-band permutational-symmetry descriptor. + /// NOT referenced by hashing, canonicalization, bliss-graph, or export. + SlotSymmetry slot_symmetry_{}; }; struct EvalOpSetter { void set(EvalExpr& expr, EvalOp op) { expr.op_type_ = op; } void reset(EvalExpr& expr) { expr.op_type_ = std::nullopt; } + /// Set the out-of-band slot-symmetry descriptor (used by the binarize + /// deduction pass; the descriptor is not part of the node's identity). + void set_slot_symmetry(EvalExpr& expr, SlotSymmetry sym) { + expr.slot_symmetry_ = std::move(sym); + } }; struct BinarizationOptions { diff --git a/SeQuant/core/eval/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp new file mode 100644 index 0000000000..2035656e51 --- /dev/null +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -0,0 +1,328 @@ +#include + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace sequant { + +SlotSymmetry from_leaf_tensor(Tensor const& t) { + SlotSymmetry ss; + + const std::size_t bra_rank = t.bra_rank(); + const std::size_t ket_rank = t.ket_rank(); + + // Column group: invariance under permuting matched (bra[c], ket[c]) columns. + // Only the paired columns over min(bra_rank, ket_rank) form columns; unpaired + // bra/ket slots and aux are never part of a ColumnGroup (spec 1.1 R2). + if (t.column_symmetry() == ColumnSymmetry::Symm) { + const std::size_t ncols = std::min(bra_rank, ket_rank); + if (ncols >= 2) { + SlotSymmetry::ColumnGroup cg; + cg.sign = 1; + cg.cols.reserve(ncols); + for (std::size_t c = 0; c < ncols; ++c) cg.cols.push_back(c); + ss.column_groups.push_back(std::move(cg)); + } + } + + // Within-bundle (bra-only / ket-only) permutational (anti)symmetry. SeQuant's + // Symmetry attribute applies jointly to the bra and the ket bundles, so emit + // a group for each bundle of rank >= 2. + if (t.symmetry() == Symmetry::Symm || t.symmetry() == Symmetry::Antisymm) { + const std::int8_t sign = (t.symmetry() == Symmetry::Antisymm) ? -1 : 1; + + if (bra_rank >= 2) { + SlotSymmetry::SlotGroup bg; + bg.sign = sign; + bg.slots.reserve(bra_rank); + for (std::size_t s = 0; s < bra_rank; ++s) bg.slots.push_back(s); + ss.bra_groups.push_back(std::move(bg)); + } + if (ket_rank >= 2) { + SlotSymmetry::SlotGroup kg; + kg.sign = sign; + kg.slots.reserve(ket_rank); + for (std::size_t s = 0; s < ket_rank; ++s) kg.slots.push_back(s); + ss.ket_groups.push_back(std::move(kg)); + } + } + + return ss; +} + +namespace { + +/// Where an index sits in an operand tensor: which bundle and column. +struct SlotLoc { + enum class Bundle { Bra, Ket } bundle; + std::size_t column; ///< position within the bra (or ket) bundle + /// Index of the operand ColumnGroup this column belongs to, or nullopt if + /// the column is not part of any ColumnGroup. + std::optional column_group_idx; +}; + +/// Map index full_label -> SlotLoc for the matched columns of an operand +/// tensor. Only bra/ket slots over min(bra_rank, ket_rank) are columns; aux +/// and unpaired bra/ket slots are excluded. +/// +/// @note Keys use Index::full_label() (not label()) so that proto-indexed +/// indices with the same base label but distinct proto-indices are +/// stored as separate entries (I2 fix). +std::unordered_map column_locations( + Tensor const& t, SlotSymmetry const& sym) { + // Return the index of the ColumnGroup in sym that contains col, or nullopt. + auto which_column_group = + [&sym](std::size_t col) -> std::optional { + for (std::size_t gi = 0; gi < sym.column_groups.size(); ++gi) + if (std::find(sym.column_groups[gi].cols.begin(), + sym.column_groups[gi].cols.end(), + col) != sym.column_groups[gi].cols.end()) + return gi; + return std::nullopt; + }; + + std::unordered_map locs; + const std::size_t ncols = std::min(t.bra_rank(), t.ket_rank()); + auto const& bra = t.bra(); + auto const& ket = t.ket(); + for (std::size_t c = 0; c < ncols; ++c) { + const auto grp = which_column_group(c); + if (bra[c].nonnull()) + locs.emplace(std::wstring{bra[c].full_label()}, + SlotLoc{SlotLoc::Bundle::Bra, c, grp}); + if (ket[c].nonnull()) + locs.emplace(std::wstring{ket[c].full_label()}, + SlotLoc{SlotLoc::Bundle::Ket, c, grp}); + } + return locs; +} + +} // namespace + +SlotSymmetry adjoint(SlotSymmetry const& s) { + SlotSymmetry result; + result.column_groups = s.column_groups; + result.bra_groups = s.ket_groups; + result.ket_groups = s.bra_groups; + return result; +} + +SlotSymmetry intersect(SlotSymmetry const& a, SlotSymmetry const& b) { + SlotSymmetry result; + + // Column groups: keep a group from a iff b contains a group with the same + // sign and the same set of column positions (order-insensitive). + auto col_match = [](SlotSymmetry::ColumnGroup const& ga, + SlotSymmetry::ColumnGroup const& gb) { + if (ga.sign != gb.sign) return false; + auto ac = ga.cols; + auto bc = gb.cols; + std::sort(ac.begin(), ac.end()); + std::sort(bc.begin(), bc.end()); + return ac == bc; + }; + for (auto const& ga : a.column_groups) { + for (auto const& gb : b.column_groups) { + if (col_match(ga, gb)) { + result.column_groups.push_back(ga); + break; + } + } + } + + // Bra groups: keep a group from a iff b contains a group with the same sign + // and the same set of slot positions (order-insensitive). + auto slot_match = [](SlotSymmetry::SlotGroup const& ga, + SlotSymmetry::SlotGroup const& gb) { + if (ga.sign != gb.sign) return false; + auto as = ga.slots; + auto bs = gb.slots; + std::sort(as.begin(), as.end()); + std::sort(bs.begin(), bs.end()); + return as == bs; + }; + for (auto const& ga : a.bra_groups) { + for (auto const& gb : b.bra_groups) { + if (slot_match(ga, gb)) { + result.bra_groups.push_back(ga); + break; + } + } + } + + // Ket groups. + for (auto const& ga : a.ket_groups) { + for (auto const& gb : b.ket_groups) { + if (slot_match(ga, gb)) { + result.ket_groups.push_back(ga); + break; + } + } + } + + return result; +} + +SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, + Tensor const& result) { + SlotSymmetry ss; + + // Only tensor*tensor contributes column inheritance here. + if (!left.is_tensor() || !right.is_tensor()) return ss; + + Tensor const& lt = left.as_tensor(); + Tensor const& rt = right.as_tensor(); + + // ---- Soundness guards ---- + // The flat index->slot trace below assumes each external occupies exactly one + // slot and that the two factors are distinguishable. Two cases violate that; + // both bail to an empty descriptor (a false negative is safe, a false + // positive would corrupt a consumer). See design note + // doc/dev/specs/2026-07-01-symmetry-deduction-via-tn-canonicalization.md. + // + // Guard 1: proto-indexed externals. An index that also appears as a proto- + // index of another slot (e.g. i1 in F{a1; i1}) couples slots the trace + // treats as independent, which can yield a WRONG symmetry. If any index of + // either operand or the result carries proto-indices, decline. (This declines + // on CSV/PNO intermediates until the graph-canonicalization deducer lands.) + auto any_proto = [](Tensor const& t) { + auto has = [](auto const& bundle) { + for (auto const& idx : bundle) + if (idx.has_proto_indices()) return true; + return false; + }; + return has(t.bra()) || has(t.ket()) || has(t.aux()); + }; + if (any_proto(lt) || any_proto(rt) || any_proto(result)) return ss; + + // NB: repeated identical factors (e.g. A{a1;i1} A{a2;i2}) carry an emergent + // exchange symmetry the flat rules cannot see, but that is a SAFE false + // negative -- the deduced group is always a subset of the true symmetry (the + // 4-tuple supplier key prevents wrong cross-copy merges), so no guard is + // warranted. Recovering the emergent symmetry needs the + // graph-canonicalization deducer (Phase 0.5), not a guard. + + auto lloc = column_locations(lt, left.slot_symmetry()); + auto rloc = column_locations(rt, right.slot_symmetry()); + + // Trace a result index to the operand (0 = left, 1 = right) and slot that + // supplies it. Externals appear in exactly one operand slot. + // Keys use full_label() to avoid aliasing proto-indexed indices that share + // a base label (I2 fix). + auto trace = [&](Index const& idx) -> std::optional> { + const std::wstring key{idx.full_label()}; + if (auto it = lloc.find(key); it != lloc.end()) return {{0, it->second}}; + if (auto it = rloc.find(key); it != rloc.end()) return {{1, it->second}}; + return std::nullopt; + }; + + const std::size_t ncols = std::min(result.bra_rank(), result.ket_rank()); + auto const& rbra = result.bra(); + auto const& rket = result.ket(); + + // ---- Column-group inheritance (PPL / giant / n-column / maximal-subset) + // ---- + if (ncols >= 2) { + // A result column c inherits iff both rbra[c] and rket[c] are nonnull and + // both trace to column-grouped operand slots with known group identities. + // Cluster inheriting columns by their 4-tuple + // (bra_supplier, bra_group_idx, ket_supplier, ket_group_idx) + // so that columns from distinct source groups in the same operand are + // never merged into one oversized ColumnGroup (C1 fix). Emit one + // ColumnGroup per cluster of size >= 2, sign +1. + using ClusterKey = std::tuple; + std::map> clusters; + for (std::size_t c = 0; c < ncols; ++c) { + if (!rbra[c].nonnull() || !rket[c].nonnull()) continue; + auto b = trace(rbra[c]); + auto k = trace(rket[c]); + if (!b || !k || !b->second.column_group_idx || + !k->second.column_group_idx) + continue; + clusters[{b->first, *b->second.column_group_idx, k->first, + *k->second.column_group_idx}] + .push_back(c); + } + for (auto& [key, cols] : clusters) { + if (cols.size() < 2) continue; + SlotSymmetry::ColumnGroup cg; + cg.sign = 1; + cg.cols = std::move(cols); + ss.column_groups.push_back(std::move(cg)); + } + } + + // ---- Bra-only / ket-only group inheritance ---- + // Build full_label -> result-bundle-position maps for fast membership checks. + // Using full_label() (not label()) avoids aliasing proto-indexed indices + // that share a base label but differ in proto-indices (I2 fix). + const std::size_t rbra_rank = result.bra_rank(); + const std::size_t rket_rank = result.ket_rank(); + + std::unordered_map rbra_pos, rket_pos; + for (std::size_t p = 0; p < rbra_rank; ++p) + if (rbra[p].nonnull()) + rbra_pos.emplace(std::wstring{rbra[p].full_label()}, p); + for (std::size_t p = 0; p < rket_rank; ++p) + if (rket[p].nonnull()) + rket_pos.emplace(std::wstring{rket[p].full_label()}, p); + + // Try to inherit one operand slot-group into one result bundle. All member + // indices of the operand group must appear in the result bundle + // (whole-group-survives guard). Emits a result SlotGroup over the result + // positions, with the operand sign carried verbatim. + auto try_inherit = + [&](SlotSymmetry::SlotGroup const& og, auto const& ot_bundle, + std::size_t ot_bundle_rank, + std::unordered_map const& res_pos, + std::size_t res_rank, + container::svector& res_groups) { + if (res_rank < 2) return; + container::svector result_positions; + result_positions.reserve(og.slots.size()); + for (std::size_t s : og.slots) { + if (s >= ot_bundle_rank || !ot_bundle[s].nonnull()) return; + auto it = res_pos.find(std::wstring{ot_bundle[s].full_label()}); + if (it == res_pos.end()) return; + result_positions.push_back(it->second); + } + if (result_positions.empty()) return; + SlotSymmetry::SlotGroup rg; + rg.sign = og.sign; + rg.slots = std::move(result_positions); + res_groups.push_back(std::move(rg)); + }; + + // Check each operand's bra_groups and ket_groups for whole-group survival + // into the result bra or ket bundle. + auto inherit_from_operand = [&](Tensor const& ot, SlotSymmetry const& oss) { + for (auto const& og : oss.bra_groups) { + try_inherit(og, ot.bra(), ot.bra_rank(), rbra_pos, rbra_rank, + ss.bra_groups); + try_inherit(og, ot.bra(), ot.bra_rank(), rket_pos, rket_rank, + ss.ket_groups); + } + for (auto const& og : oss.ket_groups) { + try_inherit(og, ot.ket(), ot.ket_rank(), rbra_pos, rbra_rank, + ss.bra_groups); + try_inherit(og, ot.ket(), ot.ket_rank(), rket_pos, rket_rank, + ss.ket_groups); + } + }; + inherit_from_operand(lt, left.slot_symmetry()); + inherit_from_operand(rt, right.slot_symmetry()); + + return ss; +} + +} // namespace sequant diff --git a/SeQuant/core/eval/slot_symmetry.hpp b/SeQuant/core/eval/slot_symmetry.hpp new file mode 100644 index 0000000000..9523412dbf --- /dev/null +++ b/SeQuant/core/eval/slot_symmetry.hpp @@ -0,0 +1,189 @@ +#ifndef SEQUANT_EVAL_SLOT_SYMMETRY_HPP +#define SEQUANT_EVAL_SLOT_SYMMETRY_HPP + +#include + +#include +#include +#include + +namespace sequant { + +/// +/// \brief Out-of-band descriptor of the permutational symmetry of an +/// intermediate result tensor produced by an EvalExpr node. +/// +/// \details This struct records the exploitable index-permutation symmetry of +/// an EvalExpr's result tensor AFTER deduction (a later task). It is +/// carried out-of-band on EvalExpr (i.e. NOT embedded in the result +/// Tensor's symmetry tag) so that it does NOT influence hashing, +/// canonicalization, bliss-graph construction, or export. Default- +/// constructed instances represent "no exploitable symmetry" and are +/// considered empty. +/// +struct SlotSymmetry { + /// + /// \brief A group of matched (bra[c], ket[c]) column pairs that may be + /// permuted together (possibly with a sign). + /// + struct ColumnGroup { + /// Zero-based column indices whose (bra, ket) pairs may be permuted. + container::svector cols; + /// +1 for symmetric, -1 for antisymmetric permutation within this group. + std::int8_t sign{1}; + }; + + /// + /// \brief A group of bra (or ket) slot indices that may be permuted + /// (possibly with a sign). + /// + struct SlotGroup { + /// Zero-based slot indices (within the bra or ket) that may be permuted. + container::svector slots; + /// +1 for symmetric, -1 for antisymmetric permutation within this group. + std::int8_t sign{1}; + }; + + /// Permutation symmetry over matched (bra[c], ket[c]) column pairs. + container::svector column_groups; + + /// Permutation symmetry within the bra slots only. + container::svector bra_groups; + + /// Permutation symmetry within the ket slots only. + container::svector ket_groups; + + /// + /// \return true if this descriptor records no exploitable symmetry + /// (all group containers are empty). + /// + [[nodiscard]] bool empty() const noexcept { + return column_groups.empty() && bra_groups.empty() && ket_groups.empty(); + } + + /// + /// \brief Order-insensitive equality: two SlotSymmetry objects are equal if + /// their group containers have the same elements regardless of order. + /// + friend bool operator==(SlotSymmetry const& lhs, + SlotSymmetry const& rhs) noexcept { + auto col_eq = [](ColumnGroup const& a, ColumnGroup const& b) { + return a.sign == b.sign && a.cols == b.cols; + }; + auto slot_eq = [](SlotGroup const& a, SlotGroup const& b) { + return a.sign == b.sign && a.slots == b.slots; + }; + + if (lhs.column_groups.size() != rhs.column_groups.size()) return false; + if (lhs.bra_groups.size() != rhs.bra_groups.size()) return false; + if (lhs.ket_groups.size() != rhs.ket_groups.size()) return false; + + // Check column_groups: same multiset of ColumnGroup elements. + { + auto l = lhs.column_groups; + auto r = rhs.column_groups; + auto cmp = [](ColumnGroup const& a, ColumnGroup const& b) { + if (a.sign != b.sign) return a.sign < b.sign; + return a.cols < b.cols; + }; + std::sort(l.begin(), l.end(), cmp); + std::sort(r.begin(), r.end(), cmp); + for (std::size_t i = 0; i < l.size(); ++i) + if (!col_eq(l[i], r[i])) return false; + } + + // Check bra_groups. + { + auto l = lhs.bra_groups; + auto r = rhs.bra_groups; + auto cmp = [](SlotGroup const& a, SlotGroup const& b) { + if (a.sign != b.sign) return a.sign < b.sign; + return a.slots < b.slots; + }; + std::sort(l.begin(), l.end(), cmp); + std::sort(r.begin(), r.end(), cmp); + for (std::size_t i = 0; i < l.size(); ++i) + if (!slot_eq(l[i], r[i])) return false; + } + + // Check ket_groups. + { + auto l = lhs.ket_groups; + auto r = rhs.ket_groups; + auto cmp = [](SlotGroup const& a, SlotGroup const& b) { + if (a.sign != b.sign) return a.sign < b.sign; + return a.slots < b.slots; + }; + std::sort(l.begin(), l.end(), cmp); + std::sort(r.begin(), r.end(), cmp); + for (std::size_t i = 0; i < l.size(); ++i) + if (!slot_eq(l[i], r[i])) return false; + } + + return true; + } +}; + +class Tensor; + +/// +/// \brief Translate a leaf Tensor's permutational-symmetry attributes into a +/// SlotSymmetry descriptor over the tensor's slot positions. +/// +/// \details Maps: +/// - ColumnSymmetry::Symm -> one ColumnGroup over all matched +/// (bra[c], ket[c]) columns (c in [0, min(bra_rank, ket_rank))), sign +1. +/// - Symmetry::Symm / Antisymm on the bra-and-ket -> a bra_group over the bra +/// slots and a ket_group over the ket slots, sign +1 (Symm) or -1 +/// (Antisymm). (SeQuant's Symmetry attribute applies jointly to bra and +/// ket; the bra/ket bundles permute together, per the column-symmetry +/// invariant.) +/// - Nonsymm in every axis -> an empty descriptor. +/// +/// Single-element bra/ket bundles (rank < 2) carry no exploitable +/// within-bundle permutation, so no bra/ket group is emitted for them. +/// +SlotSymmetry from_leaf_tensor(Tensor const& t); + +class EvalExpr; + +/// +/// \brief Intersection of two descriptors: a column/bra/ket group survives iff +/// it is present in BOTH with the same sign and the same set of +/// positions (order-insensitive). Used to deduce a Sum node's descriptor +/// from its summands. Empty in -> empty out. +/// +SlotSymmetry intersect(SlotSymmetry const& a, SlotSymmetry const& b); + +/// +/// \brief Adjoint of a descriptor: swap bra_groups <-> ket_groups, preserve +/// column_groups and all signs (real-field; complex conjugation +/// deferred to spec OQ-3). +/// +SlotSymmetry adjoint(SlotSymmetry const& s); + +/// +/// \brief Deduce the SlotSymmetry of a binary-product result from its operands. +/// +/// \param left the left operand EvalExpr (with its already-deduced +/// descriptor) +/// \param right the right operand EvalExpr +/// \param result the product's result Tensor (its bra/ket slot layout is the +/// coordinate system of the returned descriptor) +/// +/// \details Phase-0 scope: the column-group inheritance rule (spec 2.3 rule 1). +/// The result's matched columns form a single ColumnGroup iff every result-bra +/// index traces to one operand's column-grouped bra (or ket) slots, every +/// result-ket index traces to one operand's column-grouped slots, and the +/// indices contracted between those two operands occupy matched positions in +/// their column groups (the PPL / giant "matched-pair swap absorbed" pattern). +/// The column-group sign composes from the operand groups (all +1 for pure +/// column symmetry). Cases that do not match (e.g. a Fock-like factor tying a +/// single column index) yield no column group. +/// +SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, + Tensor const& result); + +} // namespace sequant + +#endif // SEQUANT_EVAL_SLOT_SYMMETRY_HPP diff --git a/doc/dev/specs/2026-07-01-symmetry-deduction-via-tn-canonicalization.md b/doc/dev/specs/2026-07-01-symmetry-deduction-via-tn-canonicalization.md new file mode 100644 index 0000000000..3860a014a0 --- /dev/null +++ b/doc/dev/specs/2026-07-01-symmetry-deduction-via-tn-canonicalization.md @@ -0,0 +1,158 @@ +# Slot-symmetry deduction via TN canonicalization (design note) + +**Date:** 2026-07-01 +**Status:** design record; supersedes the deduction approach in +`2026-06-28-general-symmetry-sector-storage-design.md` (Phase 0). The Phase-0 +`SlotSymmetry` carrier and its out-of-band (1a) wiring stand; the *deduction +algorithm* is what this note replaces. +**Repos:** SeQuant (`core/eval/slot_symmetry.*`, `core/tensor_network/v3.*`), +consumed by mpqc4 CCk/CSV. + +## Problem with the Phase-0 hand-rolled rules + +Phase 0 deduces an intermediate's permutational symmetry by propagating +*declared* leaf symmetries through a **bijective index -> slot** trace +(`deduce_slot_symmetry`). That model is a limited approximation with two +failure modes: + +1. **Dependent indices break bijectivity (unsound in products).** An index can + occupy multiple slots at once, e.g. `F{a1; i1}` where `i1` is both a + ket slot and a proto-index of `a1`. The flat trace treats slots as atomic + labels and never looks inside proto-lists, so a contraction on such an index + silently couples slots the trace thinks are independent. In a product this + can yield a **false positive** (a claimed symmetry the tensor lacks) -- + precisely on the proto-indexed CSV/PNO intermediates the feature targets. + (The earlier `full_label()` fix removed only a *label collision*; it does not + fix the incidence problem.) + +2. **Emergent symmetry from topologically-equivalent tensors (incomplete).** + `A{a1;i1} A{a2;i2}` has column symmetry `{a1,i1} <-> {a2,i2}` because the two + `A` factors are interchangeable -- a property of the product's *graph*, not + of any declared leaf symmetry. Declared-symmetry propagation is structurally + blind to it (a false negative; safe but incomplete, and arguably the dominant + origin of column/particle symmetry in built intermediates). + +Root cause: symmetry of a tensor/TN is a **graph-automorphism** property, not a +declared-attribute-propagation property. + +## Correct deduction: reuse the TN canonicalizer (graph-theoretic) + +SeQuant already models everything needed in the `TensorNetworkV3` colored graph: +`TensorCore` colors encode tensor identity (=> identical factors give isomorphic +subgraphs => emergent symmetry), `SPBundle` vertices encode proto-index +dependencies (=> dependent indices respected), and `TensorBra`/`TensorKet`/ +`TensorBraKet` distinguish bra/ket/column roles. And `canonicalize_slots` +returns a `SlotCanonicalizationMetadata` carrying a `phase` (+/-1) for the slot +permutation applied (`v3.hpp:283`). + +**Deduction primitive (canonicalize-and-compare probe):** to test whether a +candidate external-slot permutation `pi` is a symmetry, canonicalize the tensor +and the `pi`-permuted tensor; if they reach the **same canonical slot-form**, +`pi` is a symmetry and the **`phase` difference is the sign**. Run a small, +structurally-motivated candidate set (column swaps, adjacent bra/ket swaps, +identical-factor exchanges); each hit is a generator + sign. Because protos and +tensor-identity are already in the graph, this subsumes **both** failure modes +above for free. (`graph->find_automorphisms(...)` -- already used in +`wick.impl.hpp:943` -- gives the whole group directly if we ever want it instead +of probing.) + +**`canonicalize_slots` is sufficient.** No group-theory engine is needed for +deduction beyond what the graph canonicalizer already provides. + +## Storage-sector canonicalization: NOT Butler-Portugal + +Two distinct "canonicalizations" must not be conflated: + +- **Symbolic double-coset** (canonicalize a tensor *expression* `S.g.D` to + decide expression equality) -- this is Butler-Portugal / xPerm. It is the very + thing SeQuant's TN/bliss canonicalizer was designed to **replace**; it scales + poorly and is mono-term-only. **We never do this for storage. BP is rejected.** +- **Numeric storage-sector** (map a concrete integer index tuple to its + canonical representative under the slot-symmetry group `G`, + sign) -- a + per-tile runtime op. + +For the symmetries we deduce (products of symmetric groups on disjoint +slot/column blocks = Young-subgroup type), the storage-canonical form is just +**sort within each symmetric block; sign = sort parity**. Classic triangular +storage, O(n log n) per tuple, no group machinery. For a general (non-block) +mono-term `G` (e.g. a pure cyclic symmetry), the canonical form is **orbit-min: +enumerate the small explicit `G` and take the minimum** -- still a for-loop +(|G| ~ 2..few hundred), still not BP/BSGS. + +## Target descriptor + +A **signed permutation group on external slots** (a set of generators + a `+/-1` +sign character), optionally with a **per-generator conjugation bit `kappa in +{id, *}`** for hermiticity-type symmetries (`T = conj(T o sigma)`). This is a +1-dimensional representation twisted by an optional antilinear (conjugation) +character -- i.e. the sign is a linear character `G -> {+/-1}`, and `kappa` a +second `Z2` character acting antilinearly. + +- The Phase-0 bespoke 3-list (`column_groups`/`bra_groups`/`ket_groups`) is a + **lossy projection** of this: it can represent Young-subgroup-type symmetries + (the common case) but not general groups (e.g. cyclic-only) or conjugation. +- Add `kappa` from the start: hermiticity pervades our integrals, it is a cheap + second character, and it is the OQ-3 gap libPerm does not cover. + +## Scope: mono-term only + +- **In scope (storage-actionable): mono-term symmetry** = the tensor spans a + 1-dim (`+/-1`) subrep of `G`. Admits a canonical fundamental domain => + triangular/canonical-sector storage. +- **Out of scope: multi-term / multidimensional-irrep symmetry** (Young/Bianchi; + relations `sum_sigma c_sigma T o sigma = 0`). Relevant to spin-adapted + higher-order CC (genuine `S_n` irrep multiplicity), but it reduces **rank**, + not the index box -- a different problem, deliberately excluded here. + +## libPerm + +Not adopted (decided). We need neither its `canonicalize()` (block-sort / +orbit-min suffices for storage) nor group intersection at storage time. If the +descriptor ever needs to hold a general (non-Young) signed group, a libPerm- +style container becomes relevant -- but the group-intersection gap (for Sum-node +deduction) and the missing conjugation character remain its limitations. + +## Interim: keep Phase 0 as a sound conservative fast-path + +Until the graph-canonicalization deducer lands (Phase 0.5), the current rules +ship **guarded to be never-wrong**. The guiding principle: **guard where +soundness cannot be proven; do not guard where the result is provably only +incomplete.** A deduced group `G_claimed` is safe for a storage consumer iff +`G_claimed` is a *subset* of the true symmetry `G_true` (finer orbits => it +under-compresses => correct); only a *superset* (claiming a non-symmetry) +loses data. + +- **Guard (proto-indexed externals) -- KEPT.** `deduce_slot_symmetry` returns + empty if any participating operand/result external carries proto-indices. The + flat index->slot trace's bijectivity precondition fails under index + dependencies, and we cannot cheaply prove the trace stays sound, so we decline + (potential false positive avoided). The deducer simply declines on CSV/PNO + until Phase 0.5. +- **Repeated identical factors -- NOT guarded (deliberately).** Failure mode 2 is + a provable false *negative*: the flat rules miss the emergent exchange + symmetry, but the 4-tuple supplier key prevents any wrong cross-copy merge, so + `G_claimed` is always a subset of `G_true`. A "same-core -> bail" guard would + drop correct claims (e.g. a genuinely inherited column group in a PPL + contraction of identical factors) with no soundness benefit. It is documented + by a test asserting the current (sound, incomplete) behavior; the emergent + symmetry is recovered by the Phase-0.5 deducer, not by a guard. + +The net effect: every non-empty descriptor is a subset of the true symmetry, so +a consumer may safely trust it. + +## Open questions for Phase 0.5 + +- **External coloring for symmetry:** canonicalization pins named (external) + indices by identity; symmetry deduction instead needs externals colored **by + type** (space x bra/ket x aux) so `Aut` can permute them. The external/internal + distinction is configurable at the call site via `named_indices` -- confirm a + by-type external coloring is expressible through the same color hook. +- **Sign extraction:** cleanest is for each leaf's `Antisymm` bundle to + contribute its induced parity as a probe permutation is applied; confirm this + is recoverable from the canonical `phase` or needs per-leaf bundle identity. +- **Conjugation (`kappa`):** how to model bra<->ket-with-conjugation in the graph + coloring / phase so hermiticity is deduced, not just permutation symmetry. +- **Sum nodes:** the sum's symmetry is the **intersection** of the summands' + groups. Compute via the graph of the combined expression, or as an explicit + group intersection (the one place a real group-intersection routine would be + needed -- and libPerm lacks it). diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index 3411d2ee0e..0e2863c234 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -41,6 +41,7 @@ target_compile_definitions(unit_tests-sequant-symb-obj PRIVATE set(eval_test_sources "test_eval_expr.cpp" "test_eval_node.cpp" + "test_slot_symmetry.cpp" ) add_library(unit_tests-sequant-eval-obj OBJECT ${eval_test_sources}) set_target_properties(unit_tests-sequant-eval-obj PROPERTIES CXX_SCAN_FOR_MODULES OFF) diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp new file mode 100644 index 0000000000..301bde5616 --- /dev/null +++ b/tests/unit/test_slot_symmetry.cpp @@ -0,0 +1,735 @@ +#include + +#include "catch2_sequant.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +TEST_CASE("slot_symmetry", "[slot_symmetry]") { + using namespace sequant; + + sequant::TensorCanonicalizer::register_instance( + std::make_shared()); + + SECTION("default descriptor is empty") { + SlotSymmetry ss{}; + REQUIRE(ss.empty()); + } + + SECTION("operator== on two default descriptors") { + SlotSymmetry ss1{}; + SlotSymmetry ss2{}; + REQUIRE(ss1 == ss2); + } + + SECTION("carrier present on a Nonsymm leaf EvalExpr - empty") { + // Explicitly Nonsymm in every axis (deserialize defaults ColumnSymmetry to + // Symm, which is no longer empty after leaf translation). + Tensor t{L"t", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + EvalExpr ee{t}; + REQUIRE(ee.slot_symmetry().empty()); + } + + SECTION("non-empty SlotSymmetry not equal to empty") { + SlotSymmetry empty{}; + + SlotSymmetry nonempty{}; + nonempty.bra_groups.push_back( + SlotSymmetry::SlotGroup{container::svector{0, 1}, 1}); + + REQUIRE(!(empty == nonempty)); + REQUIRE(!nonempty.empty()); + } + + // ---- Task 0.2: leaf descriptor translation ---- + + SECTION("leaf closed-shell g{a,b;i,j} (ColumnSymmetry::Symm) -> column grp") { + // Closed-shell two-electron integral: column-symmetric, bra/ket Nonsymm. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + EvalExpr ee{g}; + auto const& ss = ee.slot_symmetry(); + + REQUIRE(!ss.empty()); + REQUIRE(ss.column_groups.size() == 1); + REQUIRE(ss.column_groups[0].cols == container::svector{0, 1}); + REQUIRE(ss.column_groups[0].sign == 1); + REQUIRE(ss.bra_groups.empty()); + REQUIRE(ss.ket_groups.empty()); + } + + SECTION("leaf bra-antisymm -> bra_group sign -1, no column/ket group") { + // Symmetry::Antisymm implies ColumnSymmetry::Symm (the invariant). The + // descriptor records both: a column group AND an antisymmetric bra/ket + // group. The plan's bra-antisymm acceptance row is exercised by the + // bra_group sign here; a separate fully-bra-only (no column) case is + // produced via product deduction in a later task. + Tensor t{L"t", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Antisymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + EvalExpr ee{t}; + auto const& ss = ee.slot_symmetry(); + + REQUIRE(ss.bra_groups.size() == 1); + REQUIRE(ss.bra_groups[0].slots == container::svector{0, 1}); + REQUIRE(ss.bra_groups[0].sign == -1); + REQUIRE(ss.ket_groups.size() == 1); + REQUIRE(ss.ket_groups[0].slots == container::svector{0, 1}); + REQUIRE(ss.ket_groups[0].sign == -1); + } + + SECTION("leaf fully Nonsymm -> empty descriptor") { + Tensor t{L"f", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + EvalExpr ee{t}; + REQUIRE(ee.slot_symmetry().empty()); + } + + SECTION("from_leaf_tensor matches the leaf EvalExpr descriptor") { + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + EvalExpr ee{g}; + REQUIRE(from_leaf_tensor(g) == ee.slot_symmetry()); + } + + // ---- Task 0.3: product column-group inheritance (PPL / giant) ---- + + SECTION("PPL g{a,b;c,d} t{c,d;i,j} -> 2-column group {0,1} sign +1") { + // Both factors column-symmetric; the contraction pairs (c,d) symmetrically, + // so the result column symmetry is inherited. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"a_3", L"a_4"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor t{L"t", + bra(IndexList{L"a_3", L"a_4"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(g) * ex(t)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + + REQUIRE(ss.column_groups.size() == 1); + REQUIRE(ss.column_groups[0].cols == container::svector{0, 1}); + REQUIRE(ss.column_groups[0].sign == 1); + REQUIRE(ss.bra_groups.empty()); + REQUIRE(ss.ket_groups.empty()); + } + + SECTION("scalar * tensor inherits the tensor operand descriptor") { + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + // 1/2 * g{a,b;i,j}: scalar*tensor product node keeps g's column group. + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(rational{1, 2}) * ex(g)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + REQUIRE(ss.column_groups.size() == 1); + REQUIRE(ss.column_groups[0].cols == container::svector{0, 1}); + REQUIRE(ss.column_groups[0].sign == 1); + } + + SECTION("product of two Nonsymm factors -> empty descriptor") { + Tensor f{L"f", + bra(IndexList{L"a_1"}), + ket(IndexList{L"a_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + Tensor h{L"h", + bra(IndexList{L"a_2"}), + ket(IndexList{L"i_1"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(f) * ex(h)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + REQUIRE((*node).slot_symmetry().empty()); + } + + // ---- Task 0.4: column-group break (g.f gate negative) ---- + + SECTION("g{a,b;i,k} f{k,j} -> no column group (gate negative)") { + // g is a 2-column column-symmetric factor; f is a 1-column Fock-like + // factor. One ket index of g (a_3) is contracted with f's bra. The + // result r{a_1,a_2; i_1,i_2} has 2 columns, but column 1's ket (i_2) + // traces to f which carries no ColumnGroup (ncols < 2 guard in + // from_leaf_tensor), so column_grouped == false and the break rule fires. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"a_3"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor f{L"f", + bra(IndexList{L"a_3"}), + ket(IndexList{L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(g) * ex(f)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + REQUIRE((*node).slot_symmetry().column_groups.empty()); + } + + // ---- Task 0.5: Sum intersection ---- + + SECTION("intersect: same 2-column group in both -> retained") { + SlotSymmetry a, b; + a.column_groups.push_back( + SlotSymmetry::ColumnGroup{container::svector{0, 1}, 1}); + b.column_groups.push_back( + SlotSymmetry::ColumnGroup{container::svector{0, 1}, 1}); + auto result = sequant::intersect(a, b); + REQUIRE(result.column_groups.size() == 1); + REQUIRE(result.column_groups[0].cols == + container::svector{0, 1}); + REQUIRE(result.column_groups[0].sign == 1); + } + + SECTION("intersect: group in a but b is empty -> dropped") { + SlotSymmetry a, b; + a.column_groups.push_back( + SlotSymmetry::ColumnGroup{container::svector{0, 1}, 1}); + // b has no column groups + auto result = sequant::intersect(a, b); + REQUIRE(result.column_groups.empty()); + REQUIRE(result.empty()); + } + + SECTION("Sum of PPL+PPL products -> column group {0,1} retained (positive)") { + // Two PPL-style products: g1*t1 and g2*t2, both yielding r{a_1,a_2;i_1,i_2} + // with a 2-column group. Their sum must also carry the column group. + Tensor g1{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"a_3", L"a_4"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor t1{L"t", + bra(IndexList{L"a_3", L"a_4"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor g2{L"g2", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"a_5", L"a_6"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor t2{L"t2", + bra(IndexList{L"a_5", L"a_6"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(g1) * ex(t1) + + ex(g2) * ex(t2)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + REQUIRE(ss.column_groups.size() == 1); + REQUIRE(ss.column_groups[0].cols == container::svector{0, 1}); + REQUIRE(ss.column_groups[0].sign == 1); + } + + SECTION("Sum of PPL+g.f products -> column group empty (negative)") { + // First summand: PPL g{a_1,a_2;a_3,a_4} * t{a_3,a_4;i_1,i_2} + // -> 2-column group {0,1} + // Second summand: g2{a_1,a_2;i_1,a_5} * f{a_5;i_2} (g.f-like) + // -> no column group (ket of column 1 traces to f, not column-grouped) + // Sum result must have empty column group. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"a_3", L"a_4"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor t{L"t", + bra(IndexList{L"a_3", L"a_4"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor g2{L"g2", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"a_5"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor f{L"f", + bra(IndexList{L"a_5"}), + ket(IndexList{L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(g) * ex(t) + + ex(g2) * ex(f)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + REQUIRE((*node).slot_symmetry().column_groups.empty()); + } + + // ---- Task 0.6: bra-only / ket-only group inheritance ---- + + SECTION("antisymm bra group inherited whole into result bra (primary)") { + // A{a_1,a_2; i_3,i_4} Antisymm contracted on ket with B{i_3,i_4; i_1} + // Nonsymm. Result r{a_1,a_2; i_1}: bra traces whole to A's antisymm bra + // group -> result bra_group {0,1} sign -1; ket rank 1 -> no ket group; + // min(2,1)=1 -> no column group. + Tensor A{L"A", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_3", L"i_4"}), + Symmetry::Antisymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor B{L"B", + bra(IndexList{L"i_3", L"i_4"}), + ket(IndexList{L"i_1"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(A) * ex(B)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + REQUIRE(ss.bra_groups.size() == 1); + REQUIRE(ss.bra_groups[0].slots == container::svector{0, 1}); + REQUIRE(ss.bra_groups[0].sign == -1); + REQUIRE(ss.ket_groups.empty()); + REQUIRE(ss.column_groups.empty()); + } + + SECTION("antisymm bra group partially contracted -> no bra_group (break)") { + // A{a_1,a_2,a_3; i_3,i_4} Antisymm: bra_group {0,1,2} sign -1. + // B{i_3,i_4; a_3,i_1} Nonsymm: contracts i_3,i_4 (A ket) and a_3 (A + // bra). Result r{a_1,a_2; i_1}: only a_1,a_2 survive in result bra, not + // a_3. Whole-group guard fires: no bra_group emitted. + Tensor A{L"A", + bra(IndexList{L"a_1", L"a_2", L"a_3"}), + ket(IndexList{L"i_3", L"i_4"}), + Symmetry::Antisymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor B{L"B", + bra(IndexList{L"i_3", L"i_4"}), + ket(IndexList{L"a_3", L"i_1"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(A) * ex(B)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + REQUIRE(ss.bra_groups.empty()); + } + + // ---- Task 0.7: n-column generalization + maximal-subset ---- + + SECTION("3-column triples: g*t both ColumnSymm -> ColumnGroup {0,1,2}") { + // g{a_1,a_2,a_3; a_4,a_5,a_6} ColumnSymm * t{a_4,a_5,a_6; i_1,i_2,i_3} + // ColumnSymm: all 3 result columns inherit symmetrically -> {0,1,2} sign + // +1. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2", L"a_3"}), + ket(IndexList{L"a_4", L"a_5", L"a_6"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor t{L"t", + bra(IndexList{L"a_4", L"a_5", L"a_6"}), + ket(IndexList{L"i_1", L"i_2", L"i_3"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(g) * ex(t)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + REQUIRE(ss.column_groups.size() == 1); + REQUIRE(ss.column_groups[0].cols == + container::svector{0, 1, 2}); + REQUIRE(ss.column_groups[0].sign == 1); + REQUIRE(ss.bra_groups.empty()); + REQUIRE(ss.ket_groups.empty()); + } + + SECTION( + "sub-group: g{a1,a2,a3;a4,a5,a6} * f{a6;a7} -> ColumnGroup {0,1} only" + " (column 2 ket from ColumnNonsymm operand)") { + // g has ColumnGroup {0,1,2} (ColumnSymm); f is ColumnSymmetry::Nonsymm + // (no column group). Contraction on a_6. Result bra=[a_1,a_2,a_3], + // ket=[a_4,a_5,a_7] (all virtual, ascending label order: a_4 col 2 + // skipped. Maximal-subset emits ColumnGroup {0,1} only; not {0,1,2}; not + // empty. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2", L"a_3"}), + ket(IndexList{L"a_4", L"a_5", L"a_6"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor f{L"f", + bra(IndexList{L"a_6"}), + ket(IndexList{L"a_7"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(g) * ex(f)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + REQUIRE(ss.column_groups.size() == 1); + REQUIRE(ss.column_groups[0].cols == container::svector{0, 1}); + REQUIRE(ss.column_groups[0].sign == 1); + } + + // ---- Task 0.8 Part A: adjoint() free function ---- + + SECTION("adjoint(s): bra_group moves to ket_group, column_group preserved") { + // Input: one bra_group {0,1} sign -1, one column_group {0,1} sign +1, + // no ket_group. Output: column_group preserved, bra_groups empty, + // ket_group {0,1} sign -1 (the original bra_group). + SlotSymmetry s; + s.column_groups.push_back( + SlotSymmetry::ColumnGroup{container::svector{0, 1}, 1}); + s.bra_groups.push_back( + SlotSymmetry::SlotGroup{container::svector{0, 1}, -1}); + + auto r = sequant::adjoint(s); + + REQUIRE(r.column_groups.size() == 1); + REQUIRE(r.column_groups[0].cols == container::svector{0, 1}); + REQUIRE(r.column_groups[0].sign == 1); + REQUIRE(r.bra_groups.empty()); + REQUIRE(r.ket_groups.size() == 1); + REQUIRE(r.ket_groups[0].slots == container::svector{0, 1}); + REQUIRE(r.ket_groups[0].sign == -1); + } + + SECTION("adjoint(adjoint(s)) == s (involution)") { + SlotSymmetry s; + s.column_groups.push_back( + SlotSymmetry::ColumnGroup{container::svector{0, 1}, 1}); + s.bra_groups.push_back( + SlotSymmetry::SlotGroup{container::svector{0, 1}, -1}); + REQUIRE(sequant::adjoint(sequant::adjoint(s)) == s); + } + + SECTION("Adjoint EvalExpr node carries swapped descriptor") { + // t{a_1,a_2; i_1}: bra_rank=2 >= 2, ket_rank=1 < 2. + // from_leaf_tensor: bra_group {0,1} sign -1 (Antisymm), no ket_group, + // no column_group (ncols = min(2,1) = 1 < 2). + // After adjoint: ket_group {0,1} sign -1, no bra_group, no column_group. + Tensor t{L"t", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1"}), + Symmetry::Antisymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + Tensor t_adj = t; + t_adj.adjoint(); + // Adjoint marker must have been applied (BraKetSymmetry::Nonsymm). + REQUIRE(!t_adj.label().empty()); + REQUIRE(t_adj.label().back() == adjoint_label); + + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto tree = binarize(ex(t_adj)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + + REQUIRE(tree->op_type() == EvalOp::Adjoint); + + auto const& ss = tree->slot_symmetry(); + REQUIRE(!ss.empty()); + // bra_groups: ket_groups of bare = empty (ket_rank=1 < 2). + REQUIRE(ss.bra_groups.empty()); + // ket_groups: bra_groups of bare = [{0,1}, -1]. + REQUIRE(ss.ket_groups.size() == 1); + REQUIRE(ss.ket_groups[0].slots == container::svector{0, 1}); + REQUIRE(ss.ket_groups[0].sign == -1); + // column_groups: preserved from bare = empty (ncols=1 < 2). + REQUIRE(ss.column_groups.empty()); + } + + // ---- Task 0.8 Part B: CSE descriptor probe ---- + // + // Observation: the CSE round-trips through Expr (to_expr -> cse_placeholder + // rebuild -> binarize). The definition tree is re-binarized from the + // original expression so its root carries the deduced descriptor. The + // reference nodes in parent trees are fresh Nonsymm leaves (built as + // ColumnSymmetry::Nonsymm in common_subexpression_elimination.hpp) and + // therefore have empty descriptors. + // + // This is a Phase-1 concern: consumers must look up the definition tree to + // learn the CSE intermediate's symmetry. No production fix in Phase 0. + + SECTION( + "CSE: definition tree root has descriptor; reference leaf is empty" + " (Phase-1 TODO)") { + auto ctx_resetter = + set_scoped_default_context(get_default_context().clone()); + IndexSpaceRegistry registry; + registry.add("a", 0b01); + registry.add("i", 0b10); + *get_default_context().mutable_index_space_registry() = registry; + + // g{a_1,a_2; a_3,a_4} * t{a_3,a_4; i_1,i_2}: column-symmetric PPL product. + // The product tree carries ColumnGroup {0,1} at its root. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"a_3", L"a_4"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor t{L"t", + bra(IndexList{L"a_3", L"a_4"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + + auto binarizer = [](auto&& expr) { + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + return binarize(std::forward(expr)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + }; + + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + EvalNode tree1 = binarize(ex(g) * ex(t)); + EvalNode tree2 = binarize(ex(g) * ex(t)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + + // Pre-CSE: both roots have non-empty descriptor (column group {0,1}). + REQUIRE(!tree1->slot_symmetry().empty()); + REQUIRE(tree1->slot_symmetry().column_groups.size() == 1u); + + std::vector> exprs; + exprs.push_back(std::move(tree1)); + exprs.push_back(std::move(tree2)); + + opt::eliminate_common_subexpressions(exprs, binarizer); + + // After CSE: exprs[0] is the definition tree (CSE1 = g*t). The definition + // is built by re-running binarize on the original product expression, so + // the root node retains the deduced ColumnGroup {0,1} descriptor. + REQUIRE(exprs.size() == 3u); + REQUIRE(!exprs[0]->slot_symmetry().empty()); + REQUIRE(exprs[0]->slot_symmetry().column_groups.size() == 1u); + + // exprs[1] and exprs[2] are the modified reference trees. Each is now a + // Product node whose effective content is a CSE placeholder leaf. The + // placeholder is constructed with ColumnSymmetry::Nonsymm (see + // common_subexpression_elimination.hpp), so from_leaf_tensor returns + // empty. The root descriptor of the reference tree is therefore empty. + // + // Phase-1 TODO: propagate the definition tree's descriptor to consumers + // that reference the CSE intermediate as a leaf. + REQUIRE(exprs[1]->slot_symmetry().empty()); + REQUIRE(exprs[2]->slot_symmetry().empty()); + } + + // ---- Review-findings regression tests (C1, I1, I2, Symm+1) ---- + + SECTION( + "C1: 3-tensor outer-product keeps two column groups separate" + " (no false group merge)") { + // L{a_1,a_2;a_3,a_4} and R{a_5,a_6;a_7,a_8} both ColumnSymm. + // L*R is an outer product with TWO column groups: {0,1} (from L, cols + // (a_1,a_3)/(a_2,a_4)) and {2,3} (from R, cols (a_5,a_7)/(a_6,a_8)). + // P{a_3,a_4,a_7,a_8;i_1,i_2,i_3,i_4} ColumnSymm contracts L*R's ket. + // + // Bug (pre-fix): deduce_slot_symmetry for (L*R)*P sees all four L*R + // columns as "column_grouped=true" (bool flattened from ANY group), so + // all four cluster under the single (bra_supplier=LR, ket_supplier=P) + // pair -> one merged ColumnGroup {0,1,2,3}. + // + // Fix: the 4-tuple key (bra_supplier, bra_group_idx, ket_supplier, + // ket_group_idx) keeps columns from L*R group 0 separate from group 1 -> + // two ColumnGroups {0,1} and {2,3}. + Tensor L{L"L", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"a_3", L"a_4"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor R{L"R", + bra(IndexList{L"a_5", L"a_6"}), + ket(IndexList{L"a_7", L"a_8"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor P{L"P", + bra(IndexList{L"a_3", L"a_4", L"a_7", L"a_8"}), + ket(IndexList{L"i_1", L"i_2", L"i_3", L"i_4"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(L) * ex(R) * ex(P)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + auto const& ss = (*node).slot_symmetry(); + + // With the C1 fix: two separate ColumnGroups {0,1} and {2,3}. + // Without the fix: one merged ColumnGroup {0,1,2,3} (false positive). + REQUIRE(ss.column_groups.size() == 2); + for (auto const& cg : ss.column_groups) { + REQUIRE(cg.cols.size() == 2); + REQUIRE(cg.sign == 1); + } + } + + SECTION("I1 merge_indices: scalar*tensor result descriptor is empty") { + // Under merge_indices mode, make_tensor_wo_symmetries puts all indices + // into aux (bra_rank=0, ket_rank=0). The passthrough of the operand's + // descriptor (whose slot positions refer to non-aux bra/ket) would be + // meaningless. The fix guards on rank match and returns empty instead. + Tensor g{L"g", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + BinarizationOptions opts; + opts.merge_indices = true; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(rational{1, 2}) * ex(g), + IndexSet{}, opts); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + // Without the fix the column group from g would be copied verbatim + // (column positions 0,1 now meaninglessly refer to aux slots). + REQUIRE((*node).slot_symmetry().empty()); + } + + SECTION("Guard 1: proto-indexed externals -> empty descriptor (soundness)") { + // A product whose operands carry proto-indexed externals (here a_1, + // a_1) violates the flat index->slot trace's bijectivity assumption: + // a proto-index also lives inside another slot, coupling slots the trace + // treats as independent, which can produce a WRONG symmetry. Rather than + // trust the model where its precondition fails, deduce_slot_symmetry + // declines (empty) on any proto-indexed participant. (The full_label() + // keying stays for the non-proto paths; this SECTION formerly asserted an + // inherited bra_group, before the guard was added -- an intended change.) + auto ctx_resetter = + set_scoped_default_context(get_default_context().clone()); + IndexSpaceRegistry registry; + registry.add("a", 0b01); + registry.add("i", 0b10); + *get_default_context().mutable_index_space_registry() = registry; + + Index i1(L"i_1"), i2(L"i_2"); + Index a1_i1(L"a_1", {i1}); // a_1 + Index a1_i2(L"a_1", {i2}); // a_1 + + Tensor L_t{L"L", + bra(Index::index_vector{a1_i1, a1_i2}), + ket(IndexList{L"a_3", L"a_4"}), + Symmetry::Antisymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor R_t{L"R", + bra(IndexList{L"a_3", L"a_4"}), + ket(IndexList{L"a_5"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(L_t) * ex(R_t)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + // Guard 1: proto-indexed externals -> deduction declines. + REQUIRE((*node).slot_symmetry().empty()); + } + + SECTION("repeated identical factors: emergent symmetry MISSED (documented)") { + // A{a_1;i_1} * A{a_2;i_2}: the two factors are the same tensor core, so the + // outer product has an emergent column symmetry {a_1,i_1} <-> {a_2,i_2} + // (topological equivalence of the two column bundles). The flat rules + // cannot see it, so the descriptor is empty here. This is a SAFE false + // negative -- the deduced group is always a subset of the true symmetry, + // never a superset -- so it is left UNGUARDED (a guard would only drop + // correct claims elsewhere). The emergent symmetry awaits the + // graph-canonicalization deducer (Phase 0.5). This SECTION documents the + // known incompleteness. + Tensor A1{L"A", + bra(IndexList{L"a_1"}), + ket(IndexList{L"i_1"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + Tensor A2{L"A", + bra(IndexList{L"a_2"}), + ket(IndexList{L"i_2"}), + Symmetry::Nonsymm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Symm}; + SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN + auto node = binarize(ex(A1) * ex(A2)); + SEQUANT_PRAGMA_IGNORE_DEPRECATED_END + REQUIRE((*node).slot_symmetry().empty()); + } + + SECTION("Symm leaf: bra_group and ket_group with sign +1") { + // Symmetry::Symm -> sign +1 in both bra_group and ket_group. + // This path was previously untested (only Antisymm sign -1 was covered). + Tensor t{L"t", + bra(IndexList{L"a_1", L"a_2"}), + ket(IndexList{L"i_1", L"i_2"}), + Symmetry::Symm, + BraKetSymmetry::Nonsymm, + ColumnSymmetry::Nonsymm}; + EvalExpr ee{t}; + auto const& ss = ee.slot_symmetry(); + + REQUIRE(ss.bra_groups.size() == 1); + REQUIRE(ss.bra_groups[0].slots == container::svector{0, 1}); + REQUIRE(ss.bra_groups[0].sign == 1); + REQUIRE(ss.ket_groups.size() == 1); + REQUIRE(ss.ket_groups[0].slots == container::svector{0, 1}); + REQUIRE(ss.ket_groups[0].sign == 1); + REQUIRE(ss.column_groups.empty()); + } +}