Product: reset the memoized hash in the non-const factors() - #597
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses a latent correctness issue in Product where the memoized hash could become stale after callers mutate the factors_ container via the non-const Product::factors() accessor, which can lead to incorrect static_equal() results when assertions are disabled.
Changes:
- Invalidate (
reset_hash_value()) the memoized hash whenever the non-constProduct::factors()accessor is called. - Add regression tests covering mutation through
factors()and growing an initially-emptyProductviafactors().
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
| SeQuant/core/expressions/product.cpp | Reset memoized hash on non-const factors() access to prevent stale-hash behavior after mutation. |
| tests/unit/test_expr.cpp | Add regression tests to ensure hash invalidation occurs for mutable factors() access, including when growing from empty. |
Suppressed comments (1)
tests/unit/test_expr.cpp:979
- This comment attributes the
!empty()-guarded hash invalidation pattern to both begin_subexpr()/end_subexpr(), but only begin_subexpr() currently has the guard and performs reset_hash_value(). Consider narrowing the reference to the actual code path so the rationale stays accurate.
// an empty Product can still be *grown* through the mutable factors()
// reference, so the invalidation must not be guarded by
// `!factors_.empty()` the way begin_subexpr()/end_subexpr() are
auto prod = ex<Product>(ExprPtrList{});
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // N.B. handing out a mutable reference to factors_ invalidates the memoized | ||
| // hash. Unlike begin_subexpr()/end_subexpr(), this must not be guarded by | ||
| // `!factors_.empty()`: those hand out iterators, and an empty range yields | ||
| // nothing to dereference, whereas the caller can grow an empty factors_ | ||
| // through this reference (e.g. push_back()). |
There was a problem hiding this comment.
Stale base. #594 merged at 13:08 UTC, this review ran at 13:11, and the branch was still on ea830c11c (pre-#594) — where only begin_subexpr() reset the hash.
On current master both do, each under the !factors_.empty() guard:
ExprIterator Product::end_subexpr() {
// N.B. handing out a mutable iterator into factors_ invalidates the
// memoized hash, regardless of which end of the range it points at
// (`*(--end())` mutates just as `*begin()` does)
if (!factors_.empty()) {
reset_hash_value();
}
...Branch rebased onto master, so the comment is accurate as written. No wording change.
| // Product::factors() non-const hands out a mutable reference into | ||
| // factors_, so it must invalidate the memoized hash, exactly as the | ||
| // non-const begin_subexpr()/end_subexpr() do |
There was a problem hiding this comment.
Same stale base as the product.cpp comment (and as the suppressed one on line 979): the review ran against ea830c11c, three minutes before #594 landed. Since #594, end_subexpr() resets the hash too, under the same !factors_.empty() guard as begin_subexpr(), so both test comments — and the !empty() rationale in the second one — describe the code accurately.
Branch rebased onto master. Re-verified on the new base: reverting only the factors() fix makes both new sections fail, and the full suite passes with it in place (Debug/THROW: 6824 assertions in 62 cases; Release/IGNORE: 310985 assertions in 63 cases).
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.
7d33a39 to
054f772
Compare
Product::factors()non-const hands out a mutable reference tofactors_without invalidating the memoized hash:Mutating a factor through it leaves the stale hash in place. With asserts enabled that trips
SEQUANT_ASSERT(*hash_value_ == compute_hash());in
Product::memoizing_hash(); withSEQUANT_ASSERT_BEHAVIOR=IGNOREit silently returns the stale value, which makesstatic_equal()short-circuit tofalseforProducts that are in fact equal.This is the same defect #594 fixes for
begin_subexpr()/end_subexpr().factors()is the remaining hole, and it is the wider one — the other two hand out iterators, this hands out the whole container.No caller in tree mutates through the reference today, so the bug is latent rather than live. Split out of the #594 review rather than folded into it, since it is independent of that PR's three fixes.
Sumneeds no equivalent change: it exposessummands()as const only.The
!empty()guard is deliberately absentbegin_subexpr()/end_subexpr()guard their reset with!factors_.empty(), which is safe there — an empty range yields nothing to dereference. It is not safe here, because a caller can grow an emptyfactors_through this reference:so the reset is unconditional. The second test below pins this down and fails if the guard is added.
Verification
Written test-first; both tests were watched failing before the fix went in:
hash invalidation on mutable factors() access8269482685011228508 != 8269482685011228508— stale hash returned verbatimhash invalidation on growing an empty Product via factors()0 != 0— with the!empty()guard added, the emptyProduct's hash survives apush_backFull unit suite, both assertion configurations:
SEQUANT_ASSERT_BEHAVIOR=THROW: 6795 assertions in 62 test cases, all passedSEQUANT_ASSERT_BEHAVIOR=IGNORE: 310956 assertions in 63 test cases, all passedNote on internal callers
Product::adjoint()(and theCProduct/NCProductoverrides) read through the non-constfactors(), as does theSEQUANT_ASSERTinadd_identical(), so those now take a spurious reset. Both are harmless —adjoint()assigns a freshly builtProductover*thisanyway, andadd_identical()only touchesscalar_, whichProduct's hash deliberately excludes. Routing those reads through the const overload would avoid the resets; left out of this PR as a separate cleanup.