From 0951484107b26da365d28cf45a404efafe7b001c Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Mon, 29 Jun 2026 08:35:36 -0400 Subject: [PATCH 01/11] eval: add out-of-band SlotSymmetry descriptor carrier on EvalExpr (Phase 0) Introduce a SlotSymmetry descriptor struct (column_groups / bra_groups / ket_groups, each a permutation group with a sign) and carry it on EvalExpr as an out-of-band, default-constructed member with a slot_symmetry() accessor. This is the data carrier only: no deduction logic, no mutators. The field is write-default-only and is intentionally NOT referenced by any identity or serialization path (canonicalize_slots, EvalExpr hashing, the TensorNetwork bliss-graph builder, or export), and no intermediate result tensor's symmetry tag is touched. Later phases will populate and consume it. --- SeQuant/core/eval/eval_expr.cpp | 5 ++ SeQuant/core/eval/eval_expr.hpp | 14 +++ SeQuant/core/eval/slot_symmetry.hpp | 129 ++++++++++++++++++++++++++++ tests/unit/CMakeLists.txt | 1 + tests/unit/test_slot_symmetry.cpp | 54 ++++++++++++ 5 files changed, 203 insertions(+) create mode 100644 SeQuant/core/eval/slot_symmetry.hpp create mode 100644 tests/unit/test_slot_symmetry.cpp diff --git a/SeQuant/core/eval/eval_expr.cpp b/SeQuant/core/eval/eval_expr.cpp index 16b8c70797..d49f8e6484 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 @@ -290,6 +291,10 @@ std::shared_ptr EvalExpr::copy_connectivity_graph() return connectivity_; } +SlotSymmetry const& EvalExpr::slot_symmetry() const noexcept { + return slot_symmetry_; +} + namespace { /// diff --git a/SeQuant/core/eval/eval_expr.hpp b/SeQuant/core/eval/eval_expr.hpp index 7063980195..06cc8dc87c 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,6 +294,10 @@ 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 { diff --git a/SeQuant/core/eval/slot_symmetry.hpp b/SeQuant/core/eval/slot_symmetry.hpp new file mode 100644 index 0000000000..c4f22b7916 --- /dev/null +++ b/SeQuant/core/eval/slot_symmetry.hpp @@ -0,0 +1,129 @@ +#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; + } +}; + +} // namespace sequant + +#endif // SEQUANT_EVAL_SLOT_SYMMETRY_HPP 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..3808fef5ff --- /dev/null +++ b/tests/unit/test_slot_symmetry.cpp @@ -0,0 +1,54 @@ +#include + +#include "catch2_sequant.hpp" + +#include +#include +#include +#include +#include +#include + +#include + +namespace sequant { +// Re-use parse helper from test_eval_expr style +static Tensor parse_tensor_ss(std::wstring_view tnsr) { + return deserialize(tnsr)->as(); +} +} // namespace sequant + +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 leaf EvalExpr - default empty") { + auto t = parse_tensor_ss(L"t_{i1, i2}^{a1, a2}"); + 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()); + } +} From 822fdb4ee5aaf37101e9aaa1219b1c49981acd72 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Mon, 29 Jun 2026 14:16:02 -0400 Subject: [PATCH 02/11] eval: translate leaf Tensor symmetry into the SlotSymmetry carrier (Phase 0) Add from_leaf_tensor(Tensor const&), which maps a leaf tensor's permutational- symmetry attributes onto a SlotSymmetry descriptor over its slot positions: - ColumnSymmetry::Symm -> one ColumnGroup over the matched (bra[c],ket[c]) columns (c in [0, min(bra_rank,ket_rank))), sign +1; - Symmetry::Symm/Antisymm -> a bra_group and a ket_group over the respective bundles (rank >= 2), sign +1/-1; - Nonsymm everywhere -> empty. Unpaired bra/ket slots and aux are never placed in a ColumnGroup. The EvalExpr Tensor-leaf ctor now populates slot_symmetry_ from this. The field remains out-of-band: nothing outside the unit test reads it, so leaf nodes that now carry a non-empty descriptor do not perturb hashing, canonicalization, CSE, or export (the existing eval/optimize suites pass unchanged). --- CMakeLists.txt | 2 + SeQuant/core/eval/eval_expr.cpp | 3 +- SeQuant/core/eval/slot_symmetry.cpp | 56 +++++++++++++++++++ SeQuant/core/eval/slot_symmetry.hpp | 21 +++++++ tests/unit/test_slot_symmetry.cpp | 86 ++++++++++++++++++++++++++--- 5 files changed, 158 insertions(+), 10 deletions(-) create mode 100644 SeQuant/core/eval/slot_symmetry.cpp 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 d49f8e6484..aca13c8c23 100644 --- a/SeQuant/core/eval/eval_expr.cpp +++ b/SeQuant/core/eval/eval_expr.cpp @@ -137,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_}; diff --git a/SeQuant/core/eval/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp new file mode 100644 index 0000000000..a5b348d88d --- /dev/null +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -0,0 +1,56 @@ +#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 sequant diff --git a/SeQuant/core/eval/slot_symmetry.hpp b/SeQuant/core/eval/slot_symmetry.hpp index c4f22b7916..58ce609e99 100644 --- a/SeQuant/core/eval/slot_symmetry.hpp +++ b/SeQuant/core/eval/slot_symmetry.hpp @@ -124,6 +124,27 @@ struct SlotSymmetry { } }; +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); + } // namespace sequant #endif // SEQUANT_EVAL_SLOT_SYMMETRY_HPP diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index 3808fef5ff..635918c82f 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -7,17 +7,12 @@ #include #include #include +#include +#include #include #include -namespace sequant { -// Re-use parse helper from test_eval_expr style -static Tensor parse_tensor_ss(std::wstring_view tnsr) { - return deserialize(tnsr)->as(); -} -} // namespace sequant - TEST_CASE("slot_symmetry", "[slot_symmetry]") { using namespace sequant; @@ -35,8 +30,15 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { REQUIRE(ss1 == ss2); } - SECTION("carrier present on leaf EvalExpr - default empty") { - auto t = parse_tensor_ss(L"t_{i1, i2}^{a1, a2}"); + 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()); } @@ -51,4 +53,70 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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()); + } } From 9c98fcd674d7adced56c21b934058fa4be853686 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Mon, 29 Jun 2026 14:33:49 -0400 Subject: [PATCH 03/11] eval: deduce product column-group symmetry (PPL/giant) into the carrier (Phase 0) Add deduce_slot_symmetry(left, right, result), realizing the spec column-group inheritance rule: the result's matched columns form one ColumnGroup iff every result-bra index traces to one operand's column-grouped slots, every result-ket index traces to one operand's column-grouped slots, and the contraction between those operands pairs symmetrically (the PPL / giant matched-pair-swap pattern). Index tracing is by external label, sufficient for the Phase-0 acceptance cases. Wire it into binarize's make_prod: the tensor*tensor branch sets the result descriptor from deduce_slot_symmetry; the scalar*tensor branch and the trailing scalar-multiplied-result branch pass the tensor operand's descriptor through unchanged (a scalar factor preserves the slot layout). The descriptor is set via a new EvalOpSetter::set_slot_symmetry mutator and remains out-of-band: it does not touch the result tensor, its hash, canon indices, or connectivity graph, so the eval/optimize suites pass unchanged. --- SeQuant/core/eval/eval_expr.cpp | 36 ++++++--- SeQuant/core/eval/eval_expr.hpp | 5 ++ SeQuant/core/eval/slot_symmetry.cpp | 114 ++++++++++++++++++++++++++++ SeQuant/core/eval/slot_symmetry.hpp | 24 ++++++ tests/unit/test_slot_symmetry.cpp | 66 ++++++++++++++++ 5 files changed, 235 insertions(+), 10 deletions(-) diff --git a/SeQuant/core/eval/eval_expr.cpp b/SeQuant/core/eval/eval_expr.cpp index aca13c8c23..718abfed51 100644 --- a/SeQuant/core/eval/eval_expr.cpp +++ b/SeQuant/core/eval/eval_expr.cpp @@ -531,7 +531,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()), @@ -540,6 +540,11 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, 1, // h, nullptr}; + // A scalar factor cannot break permutational symmetry: the tensor + // operand's descriptor passes through unchanged (its slot layout is + // preserved by make_tensor_wo_symmetries). + EvalOpSetter{}.set_slot_symmetry(res, tl->slot_symmetry()); + return res; } else { // tensor * tensor container::svector subfacs; @@ -582,15 +587,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; } } }; @@ -617,6 +627,12 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, h, // nullptr}; + // The trailing scalar factor preserves the tensor sub-result's slot layout + // (make_tensor above keeps its index order), so the descriptor passes + // through unchanged. + if (left->is_tensor()) + EvalOpSetter{}.set_slot_symmetry(result, left->slot_symmetry()); + 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 06cc8dc87c..09c3b5c975 100644 --- a/SeQuant/core/eval/eval_expr.hpp +++ b/SeQuant/core/eval/eval_expr.hpp @@ -303,6 +303,11 @@ class EvalExpr { 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 index a5b348d88d..c77290466f 100644 --- a/SeQuant/core/eval/slot_symmetry.cpp +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -1,10 +1,15 @@ #include #include +#include #include +#include #include #include +#include +#include +#include namespace sequant { @@ -53,4 +58,113 @@ SlotSymmetry from_leaf_tensor(Tensor const& t) { 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 + bool column_grouped; ///< true iff this column lies in an operand ColumnGroup +}; + +/// Map index-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. +std::unordered_map column_locations( + Tensor const& t, SlotSymmetry const& sym) { + auto in_column_group = [&sym](std::size_t col) { + for (auto const& cg : sym.column_groups) + if (std::find(cg.cols.begin(), cg.cols.end(), col) != cg.cols.end()) + return true; + return false; + }; + + 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 bool grouped = in_column_group(c); + if (bra[c].nonnull()) + locs.emplace(std::wstring{bra[c].label()}, + SlotLoc{SlotLoc::Bundle::Bra, c, grouped}); + if (ket[c].nonnull()) + locs.emplace(std::wstring{ket[c].label()}, + SlotLoc{SlotLoc::Bundle::Ket, c, grouped}); + } + return locs; +} + +} // namespace + +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(); + 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. + auto trace = [&](Index const& idx) -> std::optional> { + const std::wstring key{idx.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()); + if (ncols < 2) return ss; + + auto const& rbra = result.bra(); + auto const& rket = result.ket(); + + // Identify the single bra-supplier and ket-supplier operands, and require + // every result column's bra/ket index to trace into that operand's + // column-grouped slots. + std::optional bra_supplier, ket_supplier; + bool all_columns_inherit = true; + for (std::size_t c = 0; c < ncols && all_columns_inherit; ++c) { + if (!rbra[c].nonnull() || !rket[c].nonnull()) { + all_columns_inherit = false; + break; + } + auto b = trace(rbra[c]); + auto k = trace(rket[c]); + if (!b || !k || !b->second.column_grouped || !k->second.column_grouped) { + all_columns_inherit = false; + break; + } + if (!bra_supplier) + bra_supplier = b->first; + else if (*bra_supplier != b->first) + all_columns_inherit = false; + if (!ket_supplier) + ket_supplier = k->first; + else if (*ket_supplier != k->first) + all_columns_inherit = false; + } + + if (all_columns_inherit && bra_supplier && ket_supplier) { + // The contraction between the two supplying operands is symmetric: each + // supplier carries a full column group over its supplying columns, so the + // contracted indices (the other bundle of each supplier) sit in matched + // grouped columns -- the PPL / giant matched-pair-swap pattern. (When + // bra_supplier == ket_supplier the whole column comes from one operand's + // ColumnGroup, the leaf-passthrough case.) + 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)); + } + + return ss; +} + } // namespace sequant diff --git a/SeQuant/core/eval/slot_symmetry.hpp b/SeQuant/core/eval/slot_symmetry.hpp index 58ce609e99..46d45222a3 100644 --- a/SeQuant/core/eval/slot_symmetry.hpp +++ b/SeQuant/core/eval/slot_symmetry.hpp @@ -145,6 +145,30 @@ class Tensor; /// SlotSymmetry from_leaf_tensor(Tensor const& t); +class EvalExpr; + +/// +/// \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/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index 635918c82f..03cec70335 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include @@ -119,4 +120,69 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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()); + } } From a3437eabbac08bc4fc5947c8e8b78edf07370f93 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Tue, 30 Jun 2026 13:22:14 -0400 Subject: [PATCH 04/11] eval: confirm column-group break for g.f (Phase 0) --- tests/unit/test_slot_symmetry.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index 03cec70335..ad0b306bcf 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -185,4 +185,30 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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()); + } } From eeab541716908d4f6c1ef214216f0f39bde7b29b Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Tue, 30 Jun 2026 13:34:02 -0400 Subject: [PATCH 05/11] eval: intersect summand slot-symmetry in Sum nodes (Phase 0) --- SeQuant/core/eval/eval_expr.cpp | 10 ++- SeQuant/core/eval/slot_symmetry.cpp | 56 ++++++++++++++++ SeQuant/core/eval/slot_symmetry.hpp | 8 +++ tests/unit/test_slot_symmetry.cpp | 99 +++++++++++++++++++++++++++++ 4 files changed, 170 insertions(+), 3 deletions(-) diff --git a/SeQuant/core/eval/eval_expr.cpp b/SeQuant/core/eval/eval_expr.cpp index 718abfed51..8b687caa4e 100644 --- a/SeQuant/core/eval/eval_expr.cpp +++ b/SeQuant/core/eval/eval_expr.cpp @@ -457,12 +457,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()), @@ -471,6 +472,9 @@ EvalExprNode binarize(Sum const& sum, IndexSet const& uncontract, 1, // h, // nullptr}; + EvalOpSetter{}.set_slot_symmetry( + res, intersect(left.slot_symmetry(), right.slot_symmetry())); + return res; } else { return {EvalOp::Sum, // ResultType::Scalar, // diff --git a/SeQuant/core/eval/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp index c77290466f..9ec3619623 100644 --- a/SeQuant/core/eval/slot_symmetry.cpp +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -97,6 +97,62 @@ std::unordered_map column_locations( } // namespace +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; diff --git a/SeQuant/core/eval/slot_symmetry.hpp b/SeQuant/core/eval/slot_symmetry.hpp index 46d45222a3..a0d6c14b60 100644 --- a/SeQuant/core/eval/slot_symmetry.hpp +++ b/SeQuant/core/eval/slot_symmetry.hpp @@ -147,6 +147,14 @@ 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 Deduce the SlotSymmetry of a binary-product result from its operands. /// diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index ad0b306bcf..55480058d6 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -211,4 +211,103 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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()); + } } From 2f55fd2746a62159362ebbd7dac0c7f6091be2f1 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Tue, 30 Jun 2026 13:57:50 -0400 Subject: [PATCH 06/11] eval: inherit bra-only/ket-only slot symmetry in products (Phase 0) --- SeQuant/core/eval/slot_symmetry.cpp | 131 ++++++++++++++++++++-------- tests/unit/test_slot_symmetry.cpp | 54 ++++++++++++ 2 files changed, 148 insertions(+), 37 deletions(-) diff --git a/SeQuant/core/eval/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp index 9ec3619623..a212402857 100644 --- a/SeQuant/core/eval/slot_symmetry.cpp +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -175,50 +175,107 @@ SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, }; const std::size_t ncols = std::min(result.bra_rank(), result.ket_rank()); - if (ncols < 2) return ss; - auto const& rbra = result.bra(); auto const& rket = result.ket(); - // Identify the single bra-supplier and ket-supplier operands, and require - // every result column's bra/ket index to trace into that operand's - // column-grouped slots. - std::optional bra_supplier, ket_supplier; - bool all_columns_inherit = true; - for (std::size_t c = 0; c < ncols && all_columns_inherit; ++c) { - if (!rbra[c].nonnull() || !rket[c].nonnull()) { - all_columns_inherit = false; - break; + // ---- Column-group inheritance (PPL / giant) ---- + if (ncols >= 2) { + // Identify the single bra-supplier and ket-supplier operands, and require + // every result column's bra/ket index to trace into that operand's + // column-grouped slots. + std::optional bra_supplier, ket_supplier; + bool all_columns_inherit = true; + for (std::size_t c = 0; c < ncols && all_columns_inherit; ++c) { + if (!rbra[c].nonnull() || !rket[c].nonnull()) { + all_columns_inherit = false; + break; + } + auto b = trace(rbra[c]); + auto k = trace(rket[c]); + if (!b || !k || !b->second.column_grouped || !k->second.column_grouped) { + all_columns_inherit = false; + break; + } + if (!bra_supplier) + bra_supplier = b->first; + else if (*bra_supplier != b->first) + all_columns_inherit = false; + if (!ket_supplier) + ket_supplier = k->first; + else if (*ket_supplier != k->first) + all_columns_inherit = false; } - auto b = trace(rbra[c]); - auto k = trace(rket[c]); - if (!b || !k || !b->second.column_grouped || !k->second.column_grouped) { - all_columns_inherit = false; - break; + + if (all_columns_inherit && bra_supplier && ket_supplier) { + // The contraction between the two supplying operands is symmetric: each + // supplier carries a full column group over its supplying columns, so the + // contracted indices (the other bundle of each supplier) sit in matched + // grouped columns -- the PPL / giant matched-pair-swap pattern. (When + // bra_supplier == ket_supplier the whole column comes from one operand's + // ColumnGroup, the leaf-passthrough case.) + 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)); } - if (!bra_supplier) - bra_supplier = b->first; - else if (*bra_supplier != b->first) - all_columns_inherit = false; - if (!ket_supplier) - ket_supplier = k->first; - else if (*ket_supplier != k->first) - all_columns_inherit = false; } - if (all_columns_inherit && bra_supplier && ket_supplier) { - // The contraction between the two supplying operands is symmetric: each - // supplier carries a full column group over its supplying columns, so the - // contracted indices (the other bundle of each supplier) sit in matched - // grouped columns -- the PPL / giant matched-pair-swap pattern. (When - // bra_supplier == ket_supplier the whole column comes from one operand's - // ColumnGroup, the leaf-passthrough case.) - 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)); - } + // ---- Bra-only / ket-only group inheritance ---- + // Build label -> result-bundle-position maps for fast membership checks. + 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].label()}, p); + for (std::size_t p = 0; p < rket_rank; ++p) + if (rket[p].nonnull()) rket_pos.emplace(std::wstring{rket[p].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].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; } diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index 55480058d6..f7521246cc 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -310,4 +310,58 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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()); + } } From 6238b1cff9529aa7faa725c7938405ac2d63ad14 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Tue, 30 Jun 2026 14:17:10 -0400 Subject: [PATCH 07/11] eval: maximal-subset column inheritance (n-column, sub-group, aux) (Phase 0) --- SeQuant/core/eval/slot_symmetry.cpp | 50 ++++++++--------------- tests/unit/test_slot_symmetry.cpp | 61 +++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 33 deletions(-) diff --git a/SeQuant/core/eval/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp index a212402857..2657076995 100644 --- a/SeQuant/core/eval/slot_symmetry.cpp +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -7,6 +7,7 @@ #include #include +#include #include #include #include @@ -178,45 +179,28 @@ SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, auto const& rbra = result.bra(); auto const& rket = result.ket(); - // ---- Column-group inheritance (PPL / giant) ---- + // ---- Column-group inheritance (PPL / giant / n-column / maximal-subset) + // ---- if (ncols >= 2) { - // Identify the single bra-supplier and ket-supplier operands, and require - // every result column's bra/ket index to trace into that operand's - // column-grouped slots. - std::optional bra_supplier, ket_supplier; - bool all_columns_inherit = true; - for (std::size_t c = 0; c < ncols && all_columns_inherit; ++c) { - if (!rbra[c].nonnull() || !rket[c].nonnull()) { - all_columns_inherit = false; - break; - } + // A result column c inherits iff both rbra[c] and rket[c] are nonnull and + // both trace to column-grouped operand slots. Cluster inheriting columns by + // their (bra_supplier, ket_supplier) operand-index pair; emit one + // ColumnGroup per cluster of size >= 2, sign +1. Non-inheriting columns + // (incl. aux slots and unpaired bra/ket positions) are simply excluded. + std::map, container::svector> 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_grouped || !k->second.column_grouped) { - all_columns_inherit = false; - break; - } - if (!bra_supplier) - bra_supplier = b->first; - else if (*bra_supplier != b->first) - all_columns_inherit = false; - if (!ket_supplier) - ket_supplier = k->first; - else if (*ket_supplier != k->first) - all_columns_inherit = false; + if (!b || !k || !b->second.column_grouped || !k->second.column_grouped) + continue; + clusters[{b->first, k->first}].push_back(c); } - - if (all_columns_inherit && bra_supplier && ket_supplier) { - // The contraction between the two supplying operands is symmetric: each - // supplier carries a full column group over its supplying columns, so the - // contracted indices (the other bundle of each supplier) sit in matched - // grouped columns -- the PPL / giant matched-pair-swap pattern. (When - // bra_supplier == ket_supplier the whole column comes from one operand's - // ColumnGroup, the leaf-passthrough case.) + for (auto& [supplier_pair, cols] : clusters) { + if (cols.size() < 2) continue; SlotSymmetry::ColumnGroup cg; cg.sign = 1; - cg.cols.reserve(ncols); - for (std::size_t c = 0; c < ncols; ++c) cg.cols.push_back(c); + cg.cols = std::move(cols); ss.column_groups.push_back(std::move(cg)); } } diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index f7521246cc..d815e9d262 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -364,4 +364,65 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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); + } } From 86bab1632103cae83278009da136d950efc72e31 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Tue, 30 Jun 2026 14:39:32 -0400 Subject: [PATCH 08/11] eval: adjoint slot-symmetry deduction (Phase 0) --- SeQuant/core/eval/eval_expr.cpp | 3 + SeQuant/core/eval/slot_symmetry.cpp | 8 ++ SeQuant/core/eval/slot_symmetry.hpp | 7 ++ tests/unit/test_slot_symmetry.cpp | 147 ++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/SeQuant/core/eval/eval_expr.cpp b/SeQuant/core/eval/eval_expr.cpp index 8b687caa4e..f84d70564c 100644 --- a/SeQuant/core/eval/eval_expr.cpp +++ b/SeQuant/core/eval/eval_expr.cpp @@ -429,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)}; } diff --git a/SeQuant/core/eval/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp index 2657076995..22b6e8091c 100644 --- a/SeQuant/core/eval/slot_symmetry.cpp +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -98,6 +98,14 @@ std::unordered_map column_locations( } // 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; diff --git a/SeQuant/core/eval/slot_symmetry.hpp b/SeQuant/core/eval/slot_symmetry.hpp index a0d6c14b60..9523412dbf 100644 --- a/SeQuant/core/eval/slot_symmetry.hpp +++ b/SeQuant/core/eval/slot_symmetry.hpp @@ -155,6 +155,13 @@ class EvalExpr; /// 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. /// diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index d815e9d262..208fcccd09 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -5,14 +5,17 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include +#include TEST_CASE("slot_symmetry", "[slot_symmetry]") { using namespace sequant; @@ -425,4 +428,148 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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()); + } } From 7023d61810d11457c065035f9e21c23b943b0866 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Tue, 30 Jun 2026 15:32:34 -0400 Subject: [PATCH 09/11] eval: fix slot-symmetry deduction review findings (column-group identity, merge-indices passthrough, proto-index aliasing) C1 (Critical): column-group identity collapse - SlotLoc::column_grouped bool replaced with optional column_group_idx - column_locations returns which operand ColumnGroup each column belongs to - deduce_slot_symmetry clusters on 4-tuple (bra_supplier, bra_group_idx, ket_supplier, ket_group_idx) so columns from distinct source groups in the same operand are never merged into one oversized ColumnGroup I1 (Important): passthrough descriptor survives merge_indices flattening - Three sites in eval_expr.cpp (scalar*tensor, Sum make_sum, trailing scalar) previously copied the operand's descriptor unconditionally - Fixed with a rank-match guard: only propagate when result bra_rank == operand bra_rank && result ket_rank == operand ket_rank; under merge_indices both result ranks are 0 so the guard fires and the result gets an empty descriptor matching deduce_slot_symmetry behavior I2 (Important): trace maps alias proto-indices - All trace maps in slot_symmetry.cpp keyed on label() which strips the proto-index suffix; a_1 and a_1 collided under key "a_1" - Fixed by switching all four maps (column_locations x2, rbra_pos/rket_pos builders, trace lambda) to full_label() keys; lookups and emplace are consistent on both sides I3 (hardening): added comment at make_sum site documenting that intersect relies on positional comparison and the I3 invariant holds because the Sum result is built from left.canon_indices() Tests added (test_slot_symmetry.cpp): - C1 regression: 3-tensor outer-product (L*R)*P with L,R each ColumnSymm; verifies the result carries exactly 2 separate ColumnGroups of size 2 - I1 merge_indices: scalar*tensor with BinarizationOptions.merge_indices=true gives an empty descriptor - I2 proto-index: L*R product where L's bra contains a_1 and a_1; verifies the inherited bra_group has two distinct slot positions - Symm(+1) leaf: Symmetry::Symm leaf gives bra_group and ket_group with sign +1 --- SeQuant/core/eval/eval_expr.cpp | 39 ++++++-- SeQuant/core/eval/slot_symmetry.cpp | 79 +++++++++------ tests/unit/test_slot_symmetry.cpp | 145 ++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 38 deletions(-) diff --git a/SeQuant/core/eval/eval_expr.cpp b/SeQuant/core/eval/eval_expr.cpp index f84d70564c..dd792b0a3c 100644 --- a/SeQuant/core/eval/eval_expr.cpp +++ b/SeQuant/core/eval/eval_expr.cpp @@ -475,8 +475,16 @@ EvalExprNode binarize(Sum const& sum, IndexSet const& uncontract, 1, // h, // nullptr}; - EvalOpSetter{}.set_slot_symmetry( - res, intersect(left.slot_symmetry(), right.slot_symmetry())); + // 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, // @@ -547,10 +555,14 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, 1, // h, nullptr}; - // A scalar factor cannot break permutational symmetry: the tensor - // operand's descriptor passes through unchanged (its slot layout is - // preserved by make_tensor_wo_symmetries). - EvalOpSetter{}.set_slot_symmetry(res, tl->slot_symmetry()); + // 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 @@ -635,10 +647,17 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract, nullptr}; // The trailing scalar factor preserves the tensor sub-result's slot layout - // (make_tensor above keeps its index order), so the descriptor passes - // through unchanged. - if (left->is_tensor()) - EvalOpSetter{}.set_slot_symmetry(result, left->slot_symmetry()); + // 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/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp index 22b6e8091c..c040261479 100644 --- a/SeQuant/core/eval/slot_symmetry.cpp +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace sequant { @@ -64,20 +65,30 @@ 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 - bool column_grouped; ///< true iff this column lies in an operand ColumnGroup + 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-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. +/// 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) { - auto in_column_group = [&sym](std::size_t col) { - for (auto const& cg : sym.column_groups) - if (std::find(cg.cols.begin(), cg.cols.end(), col) != cg.cols.end()) - return true; - return false; + // 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; @@ -85,13 +96,13 @@ std::unordered_map column_locations( auto const& bra = t.bra(); auto const& ket = t.ket(); for (std::size_t c = 0; c < ncols; ++c) { - const bool grouped = in_column_group(c); + const auto grp = which_column_group(c); if (bra[c].nonnull()) - locs.emplace(std::wstring{bra[c].label()}, - SlotLoc{SlotLoc::Bundle::Bra, c, grouped}); + locs.emplace(std::wstring{bra[c].full_label()}, + SlotLoc{SlotLoc::Bundle::Bra, c, grp}); if (ket[c].nonnull()) - locs.emplace(std::wstring{ket[c].label()}, - SlotLoc{SlotLoc::Bundle::Ket, c, grouped}); + locs.emplace(std::wstring{ket[c].full_label()}, + SlotLoc{SlotLoc::Bundle::Ket, c, grp}); } return locs; } @@ -176,8 +187,10 @@ SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, // 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.label()}; + 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; @@ -191,20 +204,26 @@ SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, // ---- 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. Cluster inheriting columns by - // their (bra_supplier, ket_supplier) operand-index pair; emit one - // ColumnGroup per cluster of size >= 2, sign +1. Non-inheriting columns - // (incl. aux slots and unpaired bra/ket positions) are simply excluded. - std::map, container::svector> clusters; + // 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_grouped || !k->second.column_grouped) + if (!b || !k || !b->second.column_group_idx || + !k->second.column_group_idx) continue; - clusters[{b->first, k->first}].push_back(c); + clusters[{b->first, *b->second.column_group_idx, k->first, + *k->second.column_group_idx}] + .push_back(c); } - for (auto& [supplier_pair, cols] : clusters) { + for (auto& [key, cols] : clusters) { if (cols.size() < 2) continue; SlotSymmetry::ColumnGroup cg; cg.sign = 1; @@ -214,15 +233,19 @@ SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, } // ---- Bra-only / ket-only group inheritance ---- - // Build label -> result-bundle-position maps for fast membership checks. + // 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].label()}, 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].label()}, 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 @@ -239,7 +262,7 @@ SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, 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].label()}); + auto it = res_pos.find(std::wstring{ot_bundle[s].full_label()}); if (it == res_pos.end()) return; result_positions.push_back(it->second); } diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index 208fcccd09..de53246652 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -572,4 +572,149 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { 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( + "I2 proto-index: full_label prevents base-label collision in" + " bra-group inheritance") { + // Two proto-indexed indices a_1 and a_1 share the same + // base label "a_1" but have distinct proto-indices. Under the buggy + // label()-keyed maps only one entry is stored (emplace keeps the first), + // so try_inherit maps BOTH operand bra slots to position 0 in the result + // bra -> SlotGroup {0,0} (nonsensical). With full_label() the two + // entries are distinct -> correct SlotGroup {0,1}. + 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 + + // Left: bra=[a_1, a_1], ket=[a_3, a_4], Antisymm + // -> bra_group {0,1} sign -1 from from_leaf_tensor. + 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}; + // Right: bra=[a_3, a_4], ket=[a_5], Nonsymm + 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 + auto const& ss = (*node).slot_symmetry(); + + // L's bra_group {0,1} sign -1 must be inherited into the result bra. + // Bug: rbra_pos["a_1"]=0 only -> try_inherit gives result_positions=[0,0] + // -> SlotGroup {0,0} (both map to same slot). + // Fix: rbra_pos has distinct "a_1"->0 and "a_1"->1 + // -> SlotGroup {0,1} (correct distinct positions). + REQUIRE(ss.bra_groups.size() == 1); + REQUIRE(ss.bra_groups[0].slots.size() == 2); + // The two result-bra positions must be distinct (not both 0). + REQUIRE(ss.bra_groups[0].slots[0] != ss.bra_groups[0].slots[1]); + REQUIRE(ss.bra_groups[0].sign == -1); + } + + 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()); + } } From fe1b3f15e5fb2983a9ad3134557349cfbc59b018 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Wed, 1 Jul 2026 11:33:53 -0400 Subject: [PATCH 10/11] eval: guard slot-symmetry deduction against proto-indexed externals (soundness) Proto-indexed externals break the flat index->slot trace's bijectivity assumption (an index also living inside another slot's proto-list couples slots the trace treats as independent), which could yield a wrong symmetry. deduce_slot_symmetry now declines (empty descriptor) when any operand or result external carries proto-indices. Repeated identical factors are left unguarded: they are a provable false negative (deduced group is always a subset of the true symmetry), so a guard would only drop correct claims; a test documents that incompleteness. --- SeQuant/core/eval/slot_symmetry.cpp | 30 ++++++++++++++ tests/unit/test_slot_symmetry.cpp | 61 ++++++++++++++++++----------- 2 files changed, 68 insertions(+), 23 deletions(-) diff --git a/SeQuant/core/eval/slot_symmetry.cpp b/SeQuant/core/eval/slot_symmetry.cpp index c040261479..2035656e51 100644 --- a/SeQuant/core/eval/slot_symmetry.cpp +++ b/SeQuant/core/eval/slot_symmetry.cpp @@ -182,6 +182,36 @@ SlotSymmetry deduce_slot_symmetry(EvalExpr const& left, EvalExpr const& right, 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()); diff --git a/tests/unit/test_slot_symmetry.cpp b/tests/unit/test_slot_symmetry.cpp index de53246652..301bde5616 100644 --- a/tests/unit/test_slot_symmetry.cpp +++ b/tests/unit/test_slot_symmetry.cpp @@ -645,15 +645,15 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { REQUIRE((*node).slot_symmetry().empty()); } - SECTION( - "I2 proto-index: full_label prevents base-label collision in" - " bra-group inheritance") { - // Two proto-indexed indices a_1 and a_1 share the same - // base label "a_1" but have distinct proto-indices. Under the buggy - // label()-keyed maps only one entry is stored (emplace keeps the first), - // so try_inherit maps BOTH operand bra slots to position 0 in the result - // bra -> SlotGroup {0,0} (nonsensical). With full_label() the two - // entries are distinct -> correct SlotGroup {0,1}. + 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; @@ -665,15 +665,12 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { Index a1_i1(L"a_1", {i1}); // a_1 Index a1_i2(L"a_1", {i2}); // a_1 - // Left: bra=[a_1, a_1], ket=[a_3, a_4], Antisymm - // -> bra_group {0,1} sign -1 from from_leaf_tensor. 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}; - // Right: bra=[a_3, a_4], ket=[a_5], Nonsymm Tensor R_t{L"R", bra(IndexList{L"a_3", L"a_4"}), ket(IndexList{L"a_5"}), @@ -683,18 +680,36 @@ TEST_CASE("slot_symmetry", "[slot_symmetry]") { SEQUANT_PRAGMA_IGNORE_DEPRECATED_BEGIN auto node = binarize(ex(L_t) * ex(R_t)); SEQUANT_PRAGMA_IGNORE_DEPRECATED_END - auto const& ss = (*node).slot_symmetry(); + // Guard 1: proto-indexed externals -> deduction declines. + REQUIRE((*node).slot_symmetry().empty()); + } - // L's bra_group {0,1} sign -1 must be inherited into the result bra. - // Bug: rbra_pos["a_1"]=0 only -> try_inherit gives result_positions=[0,0] - // -> SlotGroup {0,0} (both map to same slot). - // Fix: rbra_pos has distinct "a_1"->0 and "a_1"->1 - // -> SlotGroup {0,1} (correct distinct positions). - REQUIRE(ss.bra_groups.size() == 1); - REQUIRE(ss.bra_groups[0].slots.size() == 2); - // The two result-bra positions must be distinct (not both 0). - REQUIRE(ss.bra_groups[0].slots[0] != ss.bra_groups[0].slots[1]); - REQUIRE(ss.bra_groups[0].sign == -1); + 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") { From b6aeaa19e9ce2a43a94e56c13fa2046543cd83f0 Mon Sep 17 00:00:00 2001 From: Eduard Valeyev Date: Wed, 1 Jul 2026 14:27:18 -0400 Subject: [PATCH 11/11] doc: design note - slot-symmetry deduction via TN canonicalization Records the corrected design for symmetry deduction (superseding the Phase-0 hand-rolled rules' approach): deduction via canonicalize_slots canonicalize-and-compare probes on the TN colored graph (handles proto-index dependencies and emergent/repeated-tensor symmetry that the flat trace cannot); storage-sector canonicalization by block-sort / small-group orbit-min, NOT Butler-Portugal (which solves the symbolic double-coset problem the TN canonicalizer already replaces); target descriptor = signed permutation group + optional conjugation character; mono-term in scope, multi-term/multidimensional- irrep out of scope. Documents the interim proto-index soundness guard. --- ...metry-deduction-via-tn-canonicalization.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 doc/dev/specs/2026-07-01-symmetry-deduction-via-tn-canonicalization.md 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).