Clean up expression implementations - #589
Conversation
ReviewWARNING this Claude-generated, reviewed by me; some accompanying fixes are in #590 Reviewed the full diff (18 files) against The risky mechanical rewrites all check out:
Findings below. The 1.
|
They aren't universally supported by all expression subclasses. Therefore, them being defined in the general Expr API doesn't make too much sense. It's better to be notified of a missing operator via a compiler error than via a runtime exception.
This avoids situations in which important functions are not implemented for a given expression type (as was the case with NormalOperatorSequence). Thus, this gives a compiler-enforced guarantee that these functions will not just remain at the (useless) base implementations that just throw.
Since C++11 std::swap will use move semantics so this custom swap impl doesn't get us any benefit.
7ca1195 to
92c8eeb
Compare
There was a problem hiding this comment.
Pull request overview
This PR refactors SeQuant’s expression subsystem by making key Expr APIs mandatory at compile time and moving multiple expression implementations out of headers and the monolithic expr.cpp into dedicated translation units. This aligns expression behavior with stricter interfaces and reduces header/compile coupling, while updating call sites and tests accordingly.
Changes:
- Make
Expr::clone(),Expr::adjoint(),Expr::type_id(), andExpr::static_equal()pure-virtual, removing the prior “throwing default” behavior. - Split implementations for
Constant,Variable,Power,Product,Sum, andExprPtroperators into new.cppfiles; update build sources and affected call sites. - Adjust tests and selected algorithms/canonicalization code to use concrete expression operations now that generic virtual arithmetic hooks were removed.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/unit/test_expr.cpp | Updates unit tests and dummy Expr implementations to satisfy new pure-virtual interface. |
| SeQuant/core/wick.impl.hpp | Updates prefactor multiplication to use concrete expression types. |
| SeQuant/core/tensor_network/v1.cpp | Updates byproduct accumulation to use concrete Constant operations. |
| SeQuant/core/op.hpp | Adds explicit NormalOperator<...>::labels() specializations and NormalOperatorSequence cloning. |
| SeQuant/core/expressions/expr.hpp | Makes several Expr methods pure-virtual (interface hardening). |
| SeQuant/core/expressions/expr.cpp | Removes moved implementations; keeps base to_latex() throwing implementation. |
| SeQuant/core/expressions/expr_ptr.hpp | Declares non-template ExprPtr binary operators (now out-of-line). |
| SeQuant/core/expressions/expr_ptr.cpp | Defines ExprPtr operators and ExprPtr helper methods formerly in other TUs/headers. |
| SeQuant/core/expressions/expr_operators.hpp | Retains only templated mixed ExprPtr/scalar and ExprPtr/label operators. |
| SeQuant/core/expressions/constant.hpp / constant.cpp | Moves Constant implementation out-of-line. |
| SeQuant/core/expressions/variable.hpp / variable.cpp | Moves Variable implementation out-of-line. |
| SeQuant/core/expressions/power.hpp / power.cpp | Moves Power implementation out-of-line. |
| SeQuant/core/expressions/product.hpp / product.cpp | Moves Product/CProduct/NCProduct implementation out-of-line. |
| SeQuant/core/expressions/sum.hpp / sum.cpp | Moves Sum and HashingAccumulator implementation out-of-line. |
| CMakeLists.txt | Adds new .cpp files to the build. |
Suppressed comments (1)
SeQuant/core/expressions/sum.cpp:217
std::swap(*this, *new_sum)will move-assign theExprbase (including thestd::enable_shared_from_thissubobject). Sinceshared_from_this()is called immediately afterwards for logging, this can produce a bad_weak_ptr throw or an incorrect self pointer when canonicalization logging is enabled. Swap only theSumdata members (and the memoized hash) instead of swapping the full object.
auto new_sum =
(pass == npasses - 1) ? acc.make_canonicalized_sum() : acc.make_sum();
using std::swap;
swap(*this, *new_sum);
if (Logger::instance().canonicalize)
std::wcout << "Sum::canonicalize_impl (pass=" << pass
<< "): after reducing summands = "
<< to_latex_align(shared_from_this()) << std::endl;
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| void NCProduct::adjoint() { | ||
| auto adj_scalar = conj(scalar()); | ||
| using namespace ranges; | ||
| // no need to reverse for commutative product |
There was a problem hiding this comment.
Correct, and already fixed in 53e3585 — the comment was copy-pasted from CProduct::adjoint(). It now reads "factors must be reversed since they do not commute". Same commit also moved this function back to range-v3 views:: for consistency with Product::adjoint() and CProduct::adjoint() in the same file, which it had diverged from.
| #include <SeQuant/core/expressions/constant.hpp> | ||
| #include <SeQuant/core/expressions/expr_ptr.hpp> | ||
| #include <SeQuant/core/io/latex/latex.hpp> | ||
| #include <SeQuant/core/utility/exception.hpp> | ||
| #include <SeQuant/core/utility/macros.hpp> | ||
|
|
There was a problem hiding this comment.
The include-hygiene point is right and is fixed in b2a5204, but the stated consequence is not: this compiles today. constant.cpp includes constant.hpp, which includes complex.hpp, which includes hash.hpp. Verified with clang++ -H on a non-unity build:
. SeQuant/core/expressions/constant.hpp
.. SeQuant/core/complex.hpp
... SeQuant/core/hash.hpp
It is still worth including directly — power.cpp and variable.cpp already do — so the header is now explicit.
| #include <SeQuant/core/expressions/expr_algorithms.hpp> | ||
| #include <SeQuant/core/expressions/expr_ptr.hpp> | ||
| #include <SeQuant/core/expressions/sum.hpp> | ||
| #include <SeQuant/core/logger.hpp> | ||
| #include <SeQuant/core/utility/macros.hpp> |
There was a problem hiding this comment.
Fixed in b2a5204, for both sum.cpp and sum.hpp. Note sum.hpp needs it in its own right, not just as a conduit: line 198 declares container::unordered_set<ExprPtr, sequant::hash::_<ExprPtr>, proportional_to>, so the header is used in a class member declaration. tensor.hpp is the precedent — it includes hash.hpp directly for the same reason.
As with the sibling comments, this was not a build break: sum.hpp includes constant.hpp -> complex.hpp -> hash.hpp.
| #include <SeQuant/core/expressions/abstract_tensor.hpp> | ||
| #include <SeQuant/core/expressions/constant.hpp> | ||
| #include <SeQuant/core/expressions/expr.hpp> | ||
| #include <SeQuant/core/expressions/expr_algorithms.hpp> | ||
| #include <SeQuant/core/expressions/expr_ptr.hpp> | ||
| #include <SeQuant/core/expressions/product.hpp> | ||
| #include <SeQuant/core/io/latex/latex.hpp> | ||
| #include <SeQuant/core/logger.hpp> | ||
| #include <SeQuant/core/tensor_canonicalizer.hpp> | ||
| #include <SeQuant/core/tensor_network.hpp> | ||
| #include <SeQuant/core/tensor_network/typedefs.hpp> |
There was a problem hiding this comment.
Fixed in b2a5204. Same as the sibling comments: not a build break — product.hpp includes constant.hpp, which reaches hash.hpp via complex.hpp — but the direct include belongs there and is now present.
Product::clone() returns ex<Product>(deep_copy()), and neither subclass overrode it, so cloning a CProduct or NCProduct silently produced a plain Product. Making Expr::clone() pure virtual does not catch this since the base override exists. The free adjoint(const ExprPtr&) is clone() followed by adjoint(), so the slice was observable: adjoint() of a CProduct reversed its factors (which CProduct::adjoint() deliberately does not do), and adjoint() of an NCProduct yielded a Product whose is_commutative() is a recursive pairwise check rather than an unconditional false -- letting canonicalization reorder factors that must not be reordered. Reachable e.g. from Sum::adjoint() over NCProduct summands.
Product::adjoint() and CProduct::adjoint() in the same file use ranges::views; only NCProduct::adjoint() had been switched to std::views while still feeding the result to ranges::begin/end. Also fix the comment, which was copy-pasted from CProduct and claimed no reversal is needed -- NCProduct::adjoint() does reverse, as it must.
The code absorbed from expr.cpp was served by nine targeted range-v3 headers; all.hpp is a well-known compile-time sink and this TU already pulls in tensor_network.hpp. Also include algorithm.hpp explicitly for bubble_sort (it only resolved via tensor_canonicalizer.hpp) and drop the now-unused <ranges>.
- drop the stray ';' after the out-of-line Product::type_id() and Sum::type_id() definitions - drop the dead 'using std::swap;' in Product::adjoint(), which no longer has a swap() call next to it now that Sum::swap() is gone - Product::is_commutative() is not memoizing; it recomputes on every call
ReviewWARNING this is Claude-generated, reviewed by me. Unlike my last pass, this one comes with fixes: I pushed four commits to this branch ( Reviewed the full diff (20 files, +1466/−1285) against merge-base My 2026-08-13 review is fully addressedAll five items landed in the 2026-08-20 force-push — One bookkeeping note: the resolved Verification of the mechanical rewritesAll eight
Every Fixed:
|
constant.cpp, product.cpp and sum.cpp call hash::value/hash::range, and sum.hpp declares a container keyed on sequant::hash::_<ExprPtr>, but none of them included <SeQuant/core/hash.hpp>. They compile only because constant.hpp pulls in complex.hpp, which pulls in hash.hpp. The sibling power.cpp, variable.cpp and tensor.hpp already include it directly. Reported by Copilot on ValeevGroup#589.
Splits the expression class implementations out of the headers and tightens the
Exprinterface. Mostly mechanical, but a few changes are behavioural — those are called out below.Motivation
expr.cpphad grown into a catch-all holding the out-of-line bodies ofConstant,Variable,Product,CProduct,NCProduct,Sum,HashingAccumulatorandExprPtr, while the rest of each class lived inline in its header. Editing any one expression type meant recompiling everything that includesexpr.hpp, and the split between "inline in the header" and "out-of-line inexpr.cpp" followed no rule.At the same time, several
Exprvirtuals had a base implementation that threw at runtime when a derived class forgot to override them. That turns a class-authoring mistake into a runtime failure in whatever code path first happens to call it.What changed
One implementation file per expression type. New
constant.cpp,variable.cpp,power.cpp,product.cpp,sum.cppandexpr_ptr.cpp;expr.cppshrinks to justExpritself. Member functions move out of the headers unless they are templates or genuinely want to be inline. Header includes are pruned to what each header actually needs, with the rest moved to the corresponding.cpp.A missing override is now a compile error.
clone(),adjoint(),type_id()andstatic_equal()are pure virtual.type_id()andstatic_equal()previously had a#if __GNUG__ { abort(); }workaround in place of= 0; that is gone.NormalOperatorSequencegains theclone()it was missing.In-place arithmetic leaves the
Exprinterface.Expr::operator*=,^=,+=and-=were virtual with throwing defaults, and onlyConstant,Product,PowerandSumever overrode them. They are now non-virtual members of those four classes, returning the derived type. The eight call sites that relied on virtual dispatch now name the type they already knew they had, e.g.Each such site was already inside an
is<T>()guard or an equivalent invariant.Memoized hashes are reset on mutation.
Variable::conjugate()andConstant::operator*=/+=/-=mutated hashed state without callingreset_hash_value(), so a laterhash_value()would trip the memoized-vs-recomputed assertion in Debug and silently return a stale hash in Release. Reachable throughSum::append, which folds constant summands in place.Power::conjugate()andVariable::set_label()already did this.NormalOperator<S>::labels()explicit specializations are now declared after the class template and before any use that would trigger implicit instantiation — an ill-formed-NDR fix.Behavioural changes
Everything above is behaviour-preserving except:
clone()no longer slicesCProduct/NCProduct.Product::clone()returnsex<Product>(deep_copy())and neither subclass overrode it, so cloning either one silently produced a plainProduct. Sincesequant::adjoint(const ExprPtr&)isclone()thenadjoint(), this was observable:adjoint()of aCProductreversed its factors (whichCProduct::adjoint()deliberately does not do), andadjoint()of anNCProductyielded aProductwhoseis_commutative()is a recursive pairwise check rather than an unconditionalfalse— letting canonicalization reorder factors that must not be reordered. Reachable fromSum::adjoint()overNCProductsummands. Both overrides are added, with regression tests.CProduct(Product&&)andNCProduct(Product&&)now move rather than copy; they readProduct(other)whereotheris a named rvalue reference, so they were silently copying.Expr::to_latex()'s exception message changed as part of dropping thenot_implemented()helper.Not addressed here
Expr::to_latex()remains a non-pure virtual with a throwing default, unlike the four that became pure. If that asymmetry is deliberate it deserves a@note; if not, it wants the same treatment. Left alone rather than guessed at.Verification
CI is a unity build, which can mask a missing include in a
.cpp, so the include changes were additionally checked against a non-unity Debug build in which every TU compiles standalone. Full unit suite passes (6694 assertions, 62 test cases).