Skip to content

Follow-ups to #593: iterator interop, checked Expr::at(), hash invalidation - #594

Merged
evaleev merged 8 commits into
masterfrom
evaleev/fix/expr-range-followups
Aug 26, 2026
Merged

Follow-ups to #593: iterator interop, checked Expr::at(), hash invalidation#594
evaleev merged 8 commits into
masterfrom
evaleev/fix/expr-range-followups

Conversation

@evaleev

@evaleev evaleev commented Aug 20, 2026

Copy link
Copy Markdown
Member

Follow-up to #593, which turned Expr into a proper range. Three defects that CI could not catch, plus regression tests for each.

Merged with master and the perf commit dropped. #589 landed in the meantime and restructured expr.hpp/expr.cpp; the merge resolves them to master's out-of-line layout, so the accessor inlining from 27f02258 is no longer part of this PR (reverted in bd4d00d9). The three correctness fixes are untouched. Section 4 below is kept as the record for the later LTO comparison.

1. const/non-const iterators do not interoperate at all

ExprIteratorImpl's heterogeneous operator- / operator== / operator<=> read other.ptr_ of the other specialization, which is private, with no friend declaration anywhere. Every one of those overloads is a hard error the moment it is instantiated:

error: 'ptr_' is a private member of 'sequant::detail::ExprIteratorImpl<true>'

Nothing in tree currently mixes the two types, which is why master builds. But expr.begin() != expr.cend() does not compile, and neither does pairing sequant::cbegin(ExprPtr const&) (expr_algorithms.hpp:65) with the non-const sequant::end(ExprPtr&) (expr_algorithms.hpp:78). There was also no ExprIteratorConstExprIterator conversion, so the const/non-const interop these overloads exist to provide was entirely non-functional.

Fixed by befriending all specializations, collapsing each <is_const>/<!is_const> overload pair into one member template, and adding a converting constructor (mutable → const only; it is a constructor template so it is never treated as a copy constructor). Also dropped operator-(difference_type, ExprIteratorImpl)n - it is not a valid random-access-iterator expression and it silently computed it - n. The valid n + it counterpart stays.

2. Expr::at() lost its bounds check

Dropping ranges::view_interface also dropped its at(), which threw when the index was out of range. The replacement forwards to operator[], whose only guard is SEQUANT_ASSERT — a no-op unless SEQUANT_ASSERT_ENABLED is #defined. In a build configured with SEQUANT_ASSERT_BEHAVIOR=IGNORE, sum.at(p) (optimize/sum.cpp:118) degrades from a thrown exception to an out-of-bounds read returning a garbage ExprPtr&. The parameter also changed from a signed difference type to std::size_t, so at(-1) went from throwing to wrapping to SIZE_MAX.

back() had the same problem from the other end: at(size() - 1) on an empty Expr — every atom — computes at(SIZE_MAX).

at() now always checks and throws sequant::Exception (project convention; nothing in tree catches std::out_of_range from here). The throwing path is out of line so it does not bloat callers. operator[] keeps its assert-only check, now documented as unchecked.

3. Product::end_subexpr() no longer invalidates the memoized hash

Product::end_cursor() used to call reset_hash_value(); the end_subexpr() that replaced it does not. So *(--product.end()) = new_factor mutates a factor while leaving the memoized hash in place — which trips the *hash_value_ == compute_hash() assert in Product::memoizing_hash(), and with asserts disabled leaves a stale hash that makes static_equal() short-circuit to false for products that are in fact equal.

Sum::begin_subexpr() gained the reset the old Sum::begin_cursor() never had, which is the right call. Sum::end_subexpr() gets it too, so both ends of both containers agree.

4. Hot accessors moved back into the header — dropped from this PR

Not part of this PR any more. 27f02258 is reverted in bd4d00d9; the accessors stay out of line in expr.cpp, per @Krzmbrzl's request. Kept below because the numbers are the baseline for the comparison to run once the LTO PR lands: master + LTO vs. this branch rebuilt on top of it.

begin/end/cbegin/cend/size/empty/operator[]/at/front/back are one-line forwarders on the hottest paths in the library. Expr::is_atom() alone is called from visit_impl(), is_scalar(), is_cnumber(), ExprRange::next_atom() and the Wick/canonicalization code; out-of-line definitions turn each into a non-inlinable cross-TU call on top of the virtual dispatch they already pay for. empty() now compares begin/end rather than computing size() == 0, saving a pair of virtual calls.

It was a restoration, not a new decision — before the switch away from ranges::view_interface these were all header-inline (as CRTP base templates), and is_atom() was ranges::empty(*this).

Measured rather than asserted. Release/-O3, SEQUANT_ASSERT_BEHAVIOR=IGNORE, Apple clang 17, against the identical tree with these bodies moved back to expr.cpp — 3 interleaved rounds of 3 repetitions, comparing per-benchmark medians over sequant_benchmarks (canonicalize, simplify, rapid_simplify, spintrace, tensor_block, random_tensor_network):

benchmarks faster inline 53 of 53
median delta −2.29%
mean delta −2.26%
best / worst −4.31% (rapid_simplify/1) / −0.09%

No code-size cost — the trivial bodies inline away rather than bloat:

artifact inline out-of-line
libSeQuant-symb.a 7 437 952 B 7 441 224 B
sequant_benchmarks 4 985 824 B 4 986 816 B

Mechanism, for the record: out-of-line, libSeQuant-symb.a alone carries 34 undefined cross-TU references to these accessors and emits 16 definitions for them; inline it has zero of either.

cc_full_derivation was excluded from the benchmark set: it segfaults, but it does so on unmodified master (3168a655) too, so it is unrelated to this branch — reported separately.

Verification

Each new test fails without its fix:

test without the fix
mixed const/non-const iteration does not compile ('ptr_' is a private member, no viable conversion)
checked element access REQUIRE_THROWS_AS(sum->at(2), Exception) fails with SEQUANT_ASSERT_BEHAVIOR=IGNORE
hash invalidation on mutable iteration fails for both Product and Sum

Full unit suite run locally in two configurations, re-run after the merge with master:

  • Debug, SEQUANT_ASSERT_BEHAVIOR=THROW: 6822 assertions in 62 test cases, all passed
  • Release, SEQUANT_ASSERT_BEHAVIOR=IGNORE: 310982 assertions in 63 test cases, all passed

Both regression tests were re-confirmed against the merged tree by reverting each fix in the Release/IGNORE build: checked element access and hash invalidation on mutable iteration fail, and pass again once restored.

CI is green on all 16 checks — GCC 14 and clang, Debug and Release, sanitizers, Valgrind.

ExprIteratorImpl's heterogeneous operator-/operator==/operator<=> read
`other.ptr_` of the *other* specialization, which is private and had no
friend declaration, so every one of them was a hard error the moment it
was instantiated. Nothing in tree mixed the two iterator types, so this
went unnoticed: `expr.begin() != expr.cend()`, or pairing
sequant::cbegin(ExprPtr const&) with sequant::end(ExprPtr&), failed to
compile. There was also no ExprIterator -> ConstExprIterator conversion.

- befriend all ExprIteratorImpl specializations and collapse each pair of
  <is_const>/<!is_const> overloads into a single member template
- add a converting constructor (mutable -> const only); it is a
  constructor template so that it is never treated as a copy constructor
- drop `operator-(difference_type, ExprIteratorImpl)`: `n - it` is not a
  valid random-access-iterator expression, and it silently computed
  `it - n` (the `n + it` counterpart is valid and stays)
- pin all of the above down with static_asserts next to the existing
  random_access_iterator ones
Dropping ranges::view_interface also dropped its at(), which threw when
the index was out of range. The replacement forwards to operator[],
whose only guard is SEQUANT_ASSERT -- a no-op unless
SEQUANT_ASSERT_ENABLED is #defined. So in a build configured with
SEQUANT_ASSERT_BEHAVIOR=IGNORE, `sum.at(p)` (e.g. optimize/sum.cpp:118)
degraded from a thrown exception to an out-of-bounds read returning a
garbage ExprPtr&. The parameter also went from a signed difference type
to std::size_t, so at(-1) went from throwing to wrapping to SIZE_MAX.

back() had the same problem from the other end: at(size() - 1) on an
empty Expr -- every atom -- computes at(SIZE_MAX).

at() now always checks and throws sequant::Exception; the cold throwing
path stays out of line so it does not bloat callers. operator[] keeps
its assert-only check, now documented as unchecked.

N.B. the exception type is sequant::Exception rather than the
std::out_of_range the range-v3 at() used to throw. Nothing in tree
catches std::out_of_range from here, and sequant::Exception is the
project convention.
begin/end/cbegin/cend/size/empty/operator[]/at/front/back are one-line
forwarders on the hottest paths in the library. Expr::is_atom() alone is
called from visit_impl(), is_scalar(), is_cnumber(),
ExprRange::next_atom() and the Wick/canonicalization code, and
out-of-line definitions turn each of these into a non-inlinable cross-TU
call on top of the virtual dispatch they already pay for. Also make
empty() compare begin/end rather than compute size() == 0, saving a pair
of virtual calls.

This is a restoration, not a new decision: before the switch away from
ranges::view_interface these were all header-inline (as CRTP base
templates), and is_atom() was `ranges::empty(*this)`.

Measured, Release/-O3, SEQUANT_ASSERT_BEHAVIOR=IGNORE, Apple clang 17,
against the identical tree with these bodies moved back to expr.cpp, 3
interleaved rounds of 3 repetitions each, comparing per-benchmark
medians over sequant_benchmarks (canonicalize, simplify,
rapid_simplify, spintrace, tensor_block, random_tensor_network):

  53 of 53 benchmarks faster; median -2.29%, mean -2.26%
  best -4.31% (rapid_simplify/1), worst -0.09%

No code-size cost -- the trivial bodies inline away rather than bloat:

  libSeQuant-symb.a    7437952 B inline vs 7441224 B out-of-line
  sequant_benchmarks   4985824 B inline vs 4986816 B out-of-line

Mechanism, for the record: out-of-line, libSeQuant-symb.a alone carries
34 undefined cross-TU references to these accessors and emits 16
definitions for them; inline, it has zero of either.

N.B. cc_full_derivation was excluded from the benchmark set -- it
segfaults, but it does so on unmodified master too, so it is unrelated
to this branch.
Product::end_cursor() used to call reset_hash_value(); the end_subexpr()
that replaced it does not, so `*(--product.end()) = new_factor` mutates a
factor while leaving the memoized hash in place. That trips the
`*hash_value_ == compute_hash()` assert in Product::memoizing_hash(), and
with asserts disabled leaves a stale hash that makes static_equal()
short-circuit to false for products that are in fact equal.

Sum::begin_subexpr() gained the reset the old Sum::begin_cursor() never
had, which is the right call -- handing out a mutable iterator into the
storage has to invalidate the hash. Give Sum::end_subexpr() the same
treatment so both ends of both containers agree.
Regression tests for the three fixes in this branch. Each fails without
its fix:

- "mixed const/non-const iteration" does not compile at all without the
  friend declaration and the converting constructor
- "checked element access" fails in a build with
  SEQUANT_ASSERT_BEHAVIOR=IGNORE, where at() used to read out of bounds
  instead of throwing (with asserts enabled the assert masks it)
- "hash invalidation on mutable iteration" fails on both Product and Sum
  when only begin_subexpr() resets the memoized hash
@evaleev
evaleev force-pushed the evaleev/fix/expr-range-followups branch from 1ac05e4 to b20c46d Compare August 20, 2026 15:53
@Krzmbrzl

Krzmbrzl commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

I don't think it is good practice to move implementations into header files unless crucial for performance.

Enabling LTO seems like the better way to address potential performance bottlenecks from these sorts of things 🤔

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR follows up on #593’s shift of Expr to a proper range by fixing const/non-const iterator interoperability, restoring checked element access semantics for Expr::at()/front()/back(), and ensuring memoized hashes are invalidated when handing out mutable iterators (including from end()), with regression tests for each.

Changes:

  • Fix ExprIteratorImpl const/non-const interop (conversion + heterogeneous comparisons/differences) and remove invalid n - it operator.
  • Restore always-checked Expr::at() (and front()/back()) that throws sequant::Exception on out-of-range access, independent of SEQUANT_ASSERT.
  • Invalidate memoized hash when returning mutable iterators from Sum::end_subexpr() and Product::end_subexpr(), plus add unit coverage.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/unit/test_expr.cpp Adds regression tests for mixed const/non-const iteration, checked access throwing, and hash invalidation on mutable iteration.
SeQuant/core/expressions/sum.hpp Resets memoized hash when handing out mutable iterators from end_subexpr().
SeQuant/core/expressions/product.hpp Resets memoized hash when handing out mutable iterators from end_subexpr().
SeQuant/core/expressions/expr.hpp Moves hot accessors inline; implements checked at() throwing Exception; documents unchecked operator[].
SeQuant/core/expressions/expr.cpp Removes out-of-line accessor bodies; adds out-of-line throwing helper to avoid inlining cold paths.
SeQuant/core/expressions/expr_iterator.hpp Fixes iterator const/non-const interop and removes invalid n - it overload; adds concept/static-assert checks.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread SeQuant/core/expressions/expr_iterator.hpp
Comment thread SeQuant/core/expressions/expr.cpp Outdated
@evaleev

evaleev commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Enabling LTO seems like the better way to address potential performance bottlenecks from these sorts of things 🤔

LTO is tricky, especially in packaging scenarios: both SQ and consumer would need to be built with LTO flags, cross-package LTO is reportedly fragile.

expr.cpp uses std::ostringstream in throw_out_of_range() and in
not_implemented(), but never included <sstream>. It compiled only
because the header arrives transitively at depth 6, via

  algorithm.hpp -> container.hpp -> hash.hpp
    -> boost/container_hash/hash.hpp -> <complex> -> <sstream>

i.e. through a Boost header and a libstdc++ implementation detail
(<complex> is not required to include <sstream>). Either link can be
dropped by an upgrade at any time.
@evaleev

evaleev commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

@Krzmbrzl LTO needs a separate PR/deeper thinking. The changes here mostly revert the inlinability to what it was before #593 ... LTO seems to be the way to go, but I don't know enough best-practices to deal with that right now

@Krzmbrzl

Krzmbrzl commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

I can create a LTO PR. However, I would consider it cleaner to move the implementations back into the CPP file.

Alternatively, I could make moving them to the CPP file part of my expr impl cleanup PR, if you prefer. I guess from the benchmark results here, we should run some benchmarks before merging that PR anyway 👀

@evaleev

evaleev commented Aug 21, 2026

Copy link
Copy Markdown
Member Author

Yes, LTO PR would be good.

Alternatively, I could make moving them to the CPP file part of my expr impl cleanup PR, if you prefer. I guess from the benchmark results here, we should run some benchmarks before meeting that PR anyway 👀

This sounds good.

Conflict resolution notes:

- expr.hpp / expr.cpp: master (#589) keeps the Expr accessors out of line,
  so the inlining from 27f0225 is dropped here, per the PR discussion
  (Krzmbrzl's cleanup PR owns the inline/out-of-line question, to be
  revisited together with LTO).  The checked at()/front()/back() semantics
  are kept, with the bounds check and throw_out_of_range() in expr.cpp.

- sum.hpp / product.hpp: master moved the *_subexpr() definitions into
  sum.cpp / product.cpp; the end_subexpr() hash invalidation moves with
  them.
@Krzmbrzl

Copy link
Copy Markdown
Collaborator

Alternatively, I could make moving them to the CPP file part of my expr impl cleanup PR, if you prefer. I guess from the benchmark results here, we should run some benchmarks before meeting that PR anyway 👀

This sounds good.

That PR has now already been merged so I would suggest this PR leaves the implementation in the CPP file, we merge the LTO PR and then do benchmarks on master and compare to the results for this PR (after having merged in to latest master, including LTO).

This reverts commit 27f0225.

Per the discussion on #594: the Expr accessors stay out of line in
expr.cpp.  The inline-vs-out-of-line question is revisited once the LTO
PR lands, by benchmarking master against this branch rebuilt on top of
it -- not here.

Deliberately empty: the merge with master (d050240) already resolved
expr.hpp/expr.cpp to master's out-of-line layout, since #589 restructured
both files.  There is nothing left to reverse; this commit records the
decision where `git log` will show it.
@evaleev

evaleev commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

That PR has now already been merged so I would suggest this PR leaves the implementation in the CPP file, we merge the LTO PR and then do benchmarks on master and compare to the results for this PR (after having merged in to latest master, including LTO).

Agreed, and already the case — I merged master in before seeing this, and #589's restructuring made it the natural resolution anyway. expr.hpp now carries declarations only; begin/end/cbegin/cend/size/empty/operator[]/at/front/back are all defined in expr.cpp. I've since reverted 27f02258 explicitly (bd4d00d9) so it doesn't take reading the merge commit to see that.

The whole diff against master is now 162 insertions over 6 files, and the only additions to expr.hpp are doc comments plus the throw_out_of_range() declaration.

Section 4 of the description is kept, marked as dropped, so the numbers are still around as the baseline for the comparison after the LTO PR lands.

Conflict resolution, for the record:

  • expr.hpp/expr.cpp — master's out-of-line layout; the checked at()/front()/back() semantics move into expr.cpp with the bounds check and throw_out_of_range().
  • sum.hpp/product.hpp — take master; both end up byte-identical to it.
  • sum.cpp/product.cpp — the end_subexpr() hash invalidation follows the definitions into the new files.

Re-ran the suite on the merged tree: Debug/THROW 6822 assertions, Release/IGNORE 310982 assertions, all passing. I also re-checked that the regression tests still earn their keep by reverting each fix in the Release/IGNORE build — checked element access and hash invalidation on mutable iteration both fail, and pass again once restored.

One thing worth splitting off, unrelated to this PR: Product::factors() non-const (product.cpp:49) hands out a mutable factors_type& with no reset_hash_value() — the same hole this PR closes for end_subexpr(). Every in-tree caller only reads through it, so it's latent rather than live, but it's still there on master.

@evaleev

evaleev commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Split the Product::factors() issue out into #597, as suggested above — it's independent of the three fixes here.

@evaleev
evaleev merged commit 453aa15 into master Aug 26, 2026
16 checks passed
@evaleev
evaleev deleted the evaleev/fix/expr-range-followups branch August 26, 2026 13:08
evaleev added a commit that referenced this pull request Aug 27, 2026
Product::factors() non-const hands out a mutable reference to factors_
without invalidating the memoized hash.  Mutating a factor through it
leaves a stale hash in place, which trips the

  SEQUANT_ASSERT(*hash_value_ == compute_hash())

in Product::memoizing_hash() when asserts are enabled, and with
SEQUANT_ASSERT_BEHAVIOR=IGNORE silently returns the stale value, making
static_equal() short-circuit to false for Products that are in fact
equal.  This is the same defect that #594 fixes for begin_subexpr() and
end_subexpr(); factors() is the remaining hole.

No caller in tree mutates through the reference today, so the bug is
latent rather than live.

The reset is deliberately *not* guarded by `!factors_.empty()` the way
begin_subexpr()/end_subexpr() are: those hand out iterators, and an empty
range yields nothing to dereference, whereas a caller can grow an empty
factors_ through this reference.  Both cases are covered by tests; the
second fails if the guard is added.

Sum needs no equivalent change -- it exposes summands() as const only.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants