fix(op): compare NormalOperatorSequence against the other operand - #590
Conversation
NormalOperatorSequence::static_equal compared the underlying vector against itself rather than against `that_cast`, so any two sequences sharing a vacuum compared equal. The hash guard did not contain the damage: NormalOperatorSequence does not override Expr::memoizing_hash, so hash_value() is always 0 and the guard always passed. The `this->empty()` early return was wrong for the same reason -- it ignored whether the other operand was empty too. Delegate to the concrete operator==, matching Operator::static_equal and NormalOperator::static_equal, and teach that operator== to compare the vacuum as well, so the two comparison paths agree on empty sequences (whose vacuum is their only distinguishing state). Also stop slicing both operands into base_type by value while comparing them.
There was a problem hiding this comment.
Pull request overview
Fixes incorrect Expr-level equality for NormalOperatorSequence by ensuring comparisons actually compare against the other operand (and by aligning static_equal with the concrete operator== behavior), with unit coverage added to prevent regressions.
Changes:
- Update
NormalOperatorSequence::static_equalto delegate to the concreteoperator==implementation. - Update
NormalOperatorSequence::operator==to compare vacuums and avoid by-value slicing of the underlying container. - Add a new unit-test section validating both concrete and
Expr-level equality, including empty-sequence vacuum distinctions.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| SeQuant/core/op.hpp | Fixes NormalOperatorSequence equality logic and aligns Expr-level and concrete comparison paths. |
| tests/unit/test_op.cpp | Adds regression tests covering concrete vs Expr-level equality, including empty-sequence vacuum behavior. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| return nopseq1.vacuum() == nopseq2.vacuum() && | ||
| static_cast<const base_type &>(nopseq1) == | ||
| static_cast<const base_type &>(nopseq2); |
There was a problem hiding this comment.
Good catch — confirmed and fixed in 2038669.
check_vacuum() only assigned vacuum_ when size() > 0, so an empty sequence built through any of the three list/pack constructors kept the Vacuum::Physical member default, while a default-constructed one took the vacuum from the context. Under the default test context (SingleProduct) those two empty sequences compared unequal.
The fix puts the empty-sequence vacuum in one place: the default constructor now routes through check_vacuum() as well, and check_vacuum() always assigns, falling back to get_default_context(S).vacuum() when there is no constituent operator to read it from. Four assertions covering this were added to SECTION("equality") and verified to fail before the change.
Now that operator== gates on vacuum(), the vacuum_ invariant is load-bearing in a second place, and check_vacuum() did not establish it for empty sequences: it only assigned vacuum_ when size() > 0, leaving the Vacuum::Physical member default in place. So a sequence built empty through any of the three list/pack constructors disagreed with a default-constructed one, which takes its vacuum from the context. Route the default constructor through check_vacuum() too and have check_vacuum() always assign, falling back to the default context's vacuum when there is no constituent operator to read it from. That puts the empty-sequence vacuum in exactly one place. Reported by Copilot on #590.
NormalOperatorSequence::static_equalcompared the underlying vector against itself instead of againstthat_cast:so any two
NormalOperatorSequences sharing a vacuum compared equal at theExprlevel. The hash guard did not contain the damage:NormalOperatorSequencedoes not overrideExpr::memoizing_hash, sohash_value()is always0and the guard always passes. Theif (this->empty()) return true;early return was wrong for the same reason — it never checked whether the other operand was empty.The concrete
operator==was fine, so the two comparison paths disagreed with each other.Fix
static_equalnow delegates to the concreteoperator==, matchingOperator::static_equalandNormalOperator::static_equal.operator==now also compares the vacuum, so both paths agree on empty sequences — for an empty sequence the vacuum is the only distinguishing state. For non-empty sequences this is redundant (check_vacuumenforces uniformity andNormalOperator::operator==already compares vacuum).operator==no longer slices both operands intobase_typeby value just to compare them.Impact
Reachable today via any
Expr-level comparison of twoNormalOperatorSequenceobjects. Note that #589 widens the exposure: it givesNormalOperatorSequenceaclone(), andSum::append/Product::appendcallfactor->clone(), so these objects can be stored into expressions — and thus fed tosimplify,add_identical, and canonicalization — where beforeclone()threw. This PR is independent of #589 and branches frommaster.Tests
New
SECTION("equality")intests/unit/test_op.cpp, verified to fail before the fix (5 assertions) and pass after. Full unit suite green (62 test cases).