Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ad41d67
tensor_network: optional conjugation byproduct in canonicalize_slots
kshitij-05 Aug 14, 2026
3cafe03
eval: consume the conjugation byproduct in the eval tree (opt-in)
kshitij-05 Aug 14, 2026
c4c867a
tensor_canonicalizer: fold Conjugate bra<->ket for flat leaves (opt-in)
kshitij-05 Aug 14, 2026
b494821
eval: pass the declared named-index comparator in the ToT leaf ctor
kshitij-05 Aug 14, 2026
b284e05
eval: extend the conjugate braket fold to ToT leaves
kshitij-05 Aug 14, 2026
02b90d4
feat(core): fold conjugate summand pairs of real-valued sums
kshitij-05 Aug 14, 2026
99a56d2
Revert "eval: extend the conjugate braket fold to ToT leaves"
kshitij-05 Aug 16, 2026
5fc3309
eval: split CSE cache keys by canon_phase (complex/Kramers correctness)
kshitij-05 Jun 26, 2026
a607f8d
eval: fold canon_phase into the eval-node identity
kshitij-05 Jul 7, 2026
ae61e9c
feat(core): pluggable conjugation map for the real-sum conjugate fold
kshitij-05 Aug 14, 2026
0ce267f
test: ToT bra-ket conjugate fold canonicalizes onto one slot
kshitij-05 Aug 16, 2026
b8d7259
Address mechanical review comments
kshitij-05 Aug 18, 2026
5730c9d
Tensor: first-class elementwise conjugation (conjugated_/conjugate())
kshitij-05 Aug 18, 2026
f1e148e
Canonicalizer emits the conjugation byproduct on the tensor itself
kshitij-05 Aug 18, 2026
90aa144
Deserializer parses conjugated tensors (label^*{...}); serialization …
kshitij-05 Aug 19, 2026
bfc7a63
Conjugate braket fold is always on
kshitij-05 Aug 19, 2026
0b6fce7
Tests: honest Hermiticity declarations, fold blessings, TNV3-only
kshitij-05 Aug 19, 2026
b6f0c4f
Lexicographic rewrite: skip named indices per edge, not by position
kshitij-05 Aug 19, 2026
7d5aace
Rules consume the value orientation: promote value_oriented to core
kshitij-05 Aug 19, 2026
baf225d
Spin transforms carry the conjugation marker through rebuilds
kshitij-05 Aug 19, 2026
cad912d
Comment: scope canonicalize_slots' conj bit to its single-tensor cons…
kshitij-05 Aug 19, 2026
547a0b3
Self-review fixes: retire the dead fold_conjugate_braket channel
kshitij-05 Aug 19, 2026
4f7df76
tests: honest NonHermitian declarations across the TA eval suite
kshitij-05 Aug 19, 2026
52f506a
pre-commit: exclude tab-separated .itfaa files from forbid-tabs
kshitij-05 Aug 19, 2026
c713dcc
create_graph edge check learns the orientation fold; remove_spin carr…
kshitij-05 Aug 19, 2026
274c14d
tests+examples: rigid braket declarations for the always-on fold
kshitij-05 Aug 19, 2026
f7bb3ec
tests: TN covariance check learns the braket orientation fold
kshitij-05 Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ repos:
hooks:
- id: remove-crlf
- id: forbid-tabs
# MPQC output and JS/XML/CSS/CMake can contain tabs
exclude: \.(out|cmake|js|xml|css)$
# MPQC output, JS/XML/CSS/CMake, and generated ITF code
# (tab-separated by format) can contain tabs
exclude: \.(out|cmake|js|xml|css)$|\.itfaa(\.expected)?$
# see https://github.com/Lucas-C/pre-commit-hooks#forbid--remove-some-unicode-characters
- repo: local
hooks:
Expand Down
156 changes: 127 additions & 29 deletions SeQuant/core/eval/eval_expr.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ bool is_tot(Tensor const& t) noexcept {
return ranges::any_of(t.const_indices(), &Index::has_proto_indices);
}

// Slot-derived metadata -- an intermediate's bra/ket partition, which fixes
// its result-column grouping -- must be computed on the unfolded spelling;
// see sequant::value_oriented (core/expressions/tensor.hpp).

} // namespace

namespace detail {
Expand Down Expand Up @@ -141,10 +145,30 @@ EvalExpr::EvalExpr(Tensor const& tnsr)
if (is_tot(tnsr)) {
ExprPtrList tlist{expr_};
auto tn = TensorNetwork(tlist);
// N.B. pass default_idxptr_slottype_lesscompare{} explicitly, NOT {}: an
// empty named_index_compare selects canonicalize_slots' internal fallback,
// which orders named indices by space() ALONE, whereas the declared default
// (default_idxptr_slottype_lesscompare) orders by proto-index count first.
// The latter is what makes a proto-indexed (ToT) leaf's canon_indices
// put occupieds first (canonicals.hpp) -- a layout downstream
// coefficient-shape detectors rely on. Passing {} here silently broke
// that, so name it explicitly.
auto md =
tn.canonicalize_slots(TensorCanonicalizer::cardinal_tensor_labels());
tn.canonicalize_slots(TensorCanonicalizer::cardinal_tensor_labels(),
nullptr, default_idxptr_slottype_lesscompare{});
hash_value_ = md.hash_value();
canon_phase_ = md.phase;
// The graph hash is orientation-shared (bra/ket of a Conjugate tensor are
// colored identically), so both orientations land on one cache slot. When
// the canonical orientation is the swapped one (md.conj), rewrite expr_
// to the canonical spelling: swap (the Conjugate adjoint) + toggle the
// elementwise-conjugation marker; binarize(Tensor) serves the marker via
// an EvalOp::Adjoint wrapper over the shared operand.
if (md.conj) {
auto& tt = expr_->as<Tensor>();
tt.adjoint();
tt.conjugate();
}
canon_indices_ = md.get_indices<index_vector>();
connectivity_ = std::move(md.graph);
} else {
Expand All @@ -154,9 +178,26 @@ EvalExpr::EvalExpr(Tensor const& tnsr)
// and it normalizes bra<->ket orientation for braket-symmetric tensors so
// that equivalent half-tensor forms (e.g. X{a;;x} and X{;a;x}) fold.
auto& t = expr_->as<Tensor>();
// apply() folds the two bra<->ket orientations of a flat Conjugate
// tensor onto the canonical one, toggling the tensor's
// elementwise-conjugation marker when it swaps
// (apply_canonical_braket_orientation);
// the hash below is that of the unconjugated spelling so both
// orientations share a cache slot, and binarize(Tensor) serves the
// marker via an EvalOp::Adjoint on retrieval.
auto phase = TensorBlockCanonicalizer{}.apply(t);
canon_phase_ = phase ? -1 : 1;
hash_value_ = hash_terminal_tensor(t);
// Leaf-hash invariant: the hash is always that of the UNSTARRED spelling,
// so the two orientations of a Conjugate tensor share one cache slot; the
// conjugation marker stays on expr_ (its symbolic spelling) and is served
// by binarize's Adjoint wrapper on retrieval.
if (t.conjugated()) {
Tensor bare{t};
bare.conjugate();
hash_value_ = hash_terminal_tensor(bare);
} else {
hash_value_ = hash_terminal_tensor(t);
}
canon_indices_ = t.const_indices() | ranges::to<index_vector>;
}
}
Expand Down Expand Up @@ -208,7 +249,19 @@ const std::optional<EvalOp>& EvalExpr::op_type() const noexcept {

ResultType EvalExpr::result_type() const noexcept { return result_type_; }

size_t EvalExpr::hash_value() const noexcept { return hash_value_; }
size_t EvalExpr::hash_value() const noexcept {
// canon_phase (+1/-1) is part of the node's *value* identity: two nodes that
// share a canonical graph/leaf but differ in antisymmetric-reorder parity
// evaluate to negatives of each other (+T vs -T), so they must not share a
// CSE cache slot. Folding it in here (rather than special-casing the
// comparator) makes every node hash carry the phase. It is a no-op for real
// closed-shell paths (every phase is +1, so all hashes shift uniformly and
// the equality structure is unchanged); complex/Kramers paths, which do
// produce -1 phases, are thereby kept apart.
auto h = hash_value_;
hash::combine(h, canon_phase_);
return h;
}

ExprPtr EvalExpr::expr() const noexcept { return expr_; }

Expand Down Expand Up @@ -386,7 +439,33 @@ EvalExprNode binarize(Variable const& v) { return EvalExprNode{EvalExpr{v}}; }

EvalExprNode binarize(Power const& p) { return EvalExprNode{EvalExpr{p}}; }

EvalExprNode binarize(Tensor const& t) {
namespace {
// Assemble the Adjoint(bare_leaf, Constant{1}) IR over the tensor `orig`,
// carrying the given slot order and phase. The right child is a sentinel
// (FullBinaryNode invariant; evaluate ignores it for EvalOp::Adjoint).
// Wrapper hash = bare-leaf hash ⊕ EvalOp::Adjoint, so the wrapped
// orientation gets its own cache slot layered over the shared operand.
// Shared by the '⁺'-marked-adjoint and Conjugate-fold paths of
// binarize(Tensor).
EvalExprNode make_adjoint_over(Tensor const& orig, EvalExprNode bare_leaf,
EvalExpr::index_vector idxs, std::int8_t phase) {
EvalExprNode sentinel{EvalExpr{Constant{1}}};
auto h = bare_leaf->hash_value();
hash::combine(h, static_cast<size_t>(EvalOp::Adjoint));
EvalExpr adj{EvalOp::Adjoint, //
ResultType::Tensor, //
orig.clone(), //
std::move(idxs), //
phase, //
h, //
nullptr};
return EvalExprNode{std::move(adj), std::move(bare_leaf),
std::move(sentinel)};
}
} // namespace

EvalExprNode binarize(Tensor const& t,
[[maybe_unused]] const BinarizationOptions& opts) {
// Detect adjoint-marked tensor leaves (label ending in U+207A '⁺'). These
// arise when the user wrote an adjoint of a BraKetSymmetry::Nonsymm tensor,
// see Tensor::adjoint() in expressions/tensor.cpp. We surface the adjoint
Expand All @@ -407,26 +486,34 @@ EvalExprNode binarize(Tensor const& t) {
bare.adjoint();
SEQUANT_ASSERT(bare.label().empty() ||
bare.label().back() != adjoint_label);
EvalExprNode bare_leaf{EvalExpr{bare}};
return make_adjoint_over(t, EvalExprNode{EvalExpr{bare}},
t.indices() | ranges::to<EvalExpr::index_vector>,
1);
}

// Sentinel right child.
EvalExprNode sentinel{EvalExpr{Constant{1}}};

// Build the Adjoint EvalExpr. Hash differs from the bare leaf so cache
// lookups don't collide.
auto h = bare_leaf->hash_value();
hash::combine(h, static_cast<size_t>(EvalOp::Adjoint));
EvalExpr adj{EvalOp::Adjoint, //
ResultType::Tensor, //
t.clone(), //
t.indices() | ranges::to<EvalExpr::index_vector>, //
1, //
h, //
nullptr};
return EvalExprNode{std::move(adj), std::move(bare_leaf),
std::move(sentinel)};
// A leaf whose canonical spelling carries the elementwise-conjugation
// marker (a BraKetSymmetry::Conjugate tensor authored in the swapped
// orientation) is served via an EvalOp::Adjoint wrapper over the bare
// (unconjugated) leaf, which holds the shared cached value. Unlike the
// '⁺' case above (an explicit Nonsymm adjoint = conjugate *and*
// transpose), the fold already put both orientations on the same canonical
// slot order, so the wrapper carries that *same* order as its operand: the
// adjoint() eval degenerates to a pure elementwise conjugation
// (result(post) = operand(pre).conj() with post == pre, no permutation).
EvalExpr leaf{t};
if (leaf.expr()->is<Tensor>() && leaf.expr()->as<Tensor>().conjugated()) {
// unstar the canonical spelling: that IS the bare operand (the fold
// already put the slots in canonical orientation)
Tensor bare{leaf.expr()->as<Tensor>()};
bare.conjugate();
EvalExprNode bare_leaf{EvalExpr{bare}};
SEQUANT_ASSERT(!bare_leaf->expr()->as<Tensor>().conjugated());
auto idxs = bare_leaf->canon_indices();
auto phase = bare_leaf->canon_phase();
return make_adjoint_over(leaf.expr()->as<Tensor>(), std::move(bare_leaf),
std::move(idxs), phase);
}
return EvalExprNode{EvalExpr{t}};
return EvalExprNode{std::move(leaf)};
}

EvalExprNode binarize(Sum const& sum, IndexSet const& uncontract,
Expand Down Expand Up @@ -455,7 +542,7 @@ EvalExprNode binarize(Sum const& sum, IndexSet const& uncontract,
EvalExpr const&) mutable -> EvalExpr {
auto h = ranges::at(hs, ++i);
if (all_tensors) {
auto const& t = left.as_tensor();
auto const t = value_oriented(left.as_tensor());
return {
EvalOp::Sum, //
ResultType::Tensor, //
Expand Down Expand Up @@ -524,7 +611,7 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract,
} else if (left->is_scalar() || right->is_scalar()) {
// scalar * tensor or tensor * scalar
auto const& tl = left->is_tensor() ? left : right;
auto const& t = tl->as_tensor();
auto const t = value_oriented(tl->as_tensor());
return {
EvalOp::Product, //
ResultType::Tensor, //
Expand All @@ -540,12 +627,22 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract,
collect_tensor_factors(left, subfacs);
collect_tensor_factors(right, subfacs);
auto ts = subfacs | transform([](auto&& t) { return t.expr; });
IndexGroups<IndexVec> const target_indices = [prod = ex<Product>(ts),
&uncontracted_idxs]() {
IndexGroups<IndexVec> const target_indices = [&ts, &uncontracted_idxs]() {
// route each surviving hyperindex to its correct slot
// (bra, ket, or aux) based on which slot it occupies in
// the factor tensors .. if appears in multiple slots put into aux
auto counts = get_used_indices_with_counts(prod);
//
// count on the value orientation of each factor: a folded Conjugate
// leaf is spelled swapped+starred but its indices occupy the authored
// slots by value; counting the folded spelling would migrate its ket
// group into bra and merge the intermediate's partition
auto unfolded = ts | transform([](ExprPtr const& x) -> ExprPtr {
if (x->is<Tensor>() && x->as<Tensor>().conjugated())
return ex<Tensor>(value_oriented(x->as<Tensor>()));
return x;
}) |
ranges::to_vector;
auto counts = get_used_indices_with_counts(ex<Product>(unfolded));
IndexGroups<IndexVec> result;
for (auto&& [k, v] : counts) {
if (v.nonproto() == 0) continue;
Expand Down Expand Up @@ -596,7 +693,8 @@ EvalExprNode binarize(Product const& prod, IndexSet const& uncontract,
auto right = binarize(Constant{prod.scalar()});

auto expr = left->is_tensor()
? detail::make_tensor(left->as_tensor(), false, opts)
? detail::make_tensor(value_oriented(left->as_tensor()),
false, opts)
: left->is_constant() ? (left->expr() * right->expr())
: detail::make_variable();
auto type = left->is_tensor() ? ResultType::Tensor : ResultType::Scalar;
Expand Down Expand Up @@ -626,7 +724,7 @@ EvalExprNode binarize(ExprPtr const& expr, IndexSet const& uncontract,
return binarize(expr->as<Variable>());

if (expr->is<Tensor>()) //
return binarize(expr->as<Tensor>());
return binarize(expr->as<Tensor>(), opts);

if (expr->is<Sum>()) //
return binarize(expr->as<Sum>(), uncontract, opts);
Expand Down
9 changes: 9 additions & 0 deletions SeQuant/core/eval/eval_expr.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ class EvalExpr {
///
/// \brief Construct an EvalExpr object from a tensor.
///
/// \param tnsr The tensor to wrap as a leaf. The two bra<->ket
/// orientations of a BraKetSymmetry::Conjugate tensor fold onto one
/// canonical spelling: expr() carries the canonical orientation with
/// the elementwise-conjugation marker (Tensor::conjugated()) set when
/// the input was the swapped orientation. The leaf hash is always
/// that of the unconjugated spelling, so the two orientations share
/// a cache slot; binarize(Tensor) serves a conjugated leaf via an
/// EvalOp::Adjoint wrapper over the shared operand.
///
explicit EvalExpr(Tensor const& tnsr);

///
Expand Down
11 changes: 11 additions & 0 deletions SeQuant/core/eval/eval_node_compare.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ struct TreeNodeEqualityComparator {
return false;
}

// canon_phase (+1/-1) is part of the node's value identity: two nodes with
// the same canonical graph/leaf but opposite antisymmetric-reorder parity
// evaluate to negatives of each other (+T vs -T) and must not share a CSE
// cache slot. It is already folded into hash_value() (so cross-phase pairs
// normally hash apart and fail the check above), which is a no-op for real
// closed-shell paths where every phase is +1; this guards the residual case
// of a hash collision, mirroring how the graph is both hashed and compared.
if (lhs->canon_phase() != rhs->canon_phase()) {
return false;
}

if (lhs->is_constant() || lhs->is_variable() || lhs->is_power()) {
if (*lhs->expr() != *rhs->expr()) {
return false;
Expand Down
5 changes: 5 additions & 0 deletions SeQuant/core/expressions/abstract_tensor.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@ class AbstractTensor {
virtual void _swap_bra_ket() {
throw missing_instantiation_for("_swap_bra_ket");
}
/// complex-conjugates the tensor elementwise (no slot reordering); see
/// Tensor::conjugate()
virtual void _conjugate() { throw missing_instantiation_for("_conjugate"); }
/// @return whether the tensor is elementwise complex-conjugated
virtual bool _conjugated() const { return false; }

/// @return mutable view of bra
/// @warning this is used for mutable access, flush memoized state before
Expand Down
55 changes: 55 additions & 0 deletions SeQuant/core/expressions/expr_algorithms.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include <iostream>
#include <string>
#include <utility>
#include <vector>

namespace sequant {

Expand Down Expand Up @@ -105,6 +106,60 @@ ExprPtr canonicalize(ExprPtr&& expr_rv, CanonicalizeOptions opts) {
return std::move(expr_rv);
}

ExprPtr fold_conjugate_pairs_of_real_sum(
ExprPtr const& expr, CanonicalizeOptions opts,
std::function<ExprPtr(ExprPtr const&)> conjugate_op) {
if (!expr || !expr->is<Sum>()) return expr;
// cross-summand identity requires meaningful named (external) labels,
// same reasoning as Sum::canonicalize_impl
opts = opts.copy_and_set(CanonicalizeOptions::IgnoreNamedIndexLabel::No);

auto const& summands = expr->as<Sum>().summands();
const std::size_t n = summands.size();
std::vector<ExprPtr> canon(n), canon_adj(n);
for (std::size_t i = 0; i != n; ++i) {
canon[i] = canonicalize(summands[i]->clone(), opts);
ExprPtr adj;
if (conjugate_op) {
adj = conjugate_op(summands[i]);
} else {
adj = summands[i]->clone();
adj->adjoint();
}
canon_adj[i] = canonicalize(std::move(adj), opts);
}

// greedy first-match pairing: i keeps its ORIGINAL form with a doubled
// scalar, its adjoint partner j is dropped. Self-adjoint summands
// (canon == canon_adj) are manifestly real and stay untouched. Pairs are
// verified structurally, not by hash alone.
std::vector<bool> dropped(n, false), doubled(n, false);
for (std::size_t i = 0; i != n; ++i) {
if (dropped[i] || doubled[i]) continue;
if (canon[i]->hash_value() == canon_adj[i]->hash_value() &&
*canon[i] == *canon_adj[i])
continue; // self-adjoint
for (std::size_t j = i + 1; j != n; ++j) {
if (dropped[j] || doubled[j]) continue;
if (canon[j]->hash_value() == canon_adj[i]->hash_value() &&
*canon[j] == *canon_adj[i]) {
doubled[i] = true;
dropped[j] = true;
break;
}
}
}

auto result = std::make_shared<Sum>();
for (std::size_t i = 0; i != n; ++i) {
if (dropped[i]) continue;
result->append(doubled[i] ? ex<Constant>(2) * summands[i]->clone()
: summands[i]->clone());
}
if (result->summands().size() == 1) return result->summands().front();
return result;
}

ResultExpr& canonicalize(ResultExpr& expr, CanonicalizeOptions opts) {
expr.expression() = canonicalize(expr.expression(), std::move(opts));

Expand Down
Loading
Loading