Skip to content

Fix #348: discriminate symbols across domains in expression identity - #442

Merged
petlenz merged 2 commits into
mainfrom
fix-348-symbol-identity
Sep 16, 2026
Merged

petlenz merged 2 commits into
mainfrom
fix-348-symbol-identity

Conversation

@petlenz

@petlenz petlenz commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #348.

A scalar symbol x and a tensor symbol x share id() (both index 0 of their own domain's type list) and hash (symbol_base::update_hash_value covers only the name). expression::operator== / operator< therefore fell through to the same-type downcast in visitable_impl, casting a tensor to a scalar.

Reproduced at 05f3c3a, worse than the issue reported:

Build Behavior
Debug assert(dynamic_cast<Derived const *>(&rhs) != nullptr) → abort, core dumped
Release (NDEBUG) SIGSEGV, core dumped (exit 139) — the wrong-type read walks a std::string out of a tensor's layout

Fix

  • expression::operator== / operator< (src/numsim_cas/core/expression.cpp): compare the dynamic type once id() and hash tie, before the downcast. operator< orders unequal types with type_info::before. Within a domain, equal id() already implies equal type, so this changes nothing there.

    RTTI is safe here: the library builds as a static archive and its typeinfo carries ordinary mangled names, so libstdc++ compares them with strcmp — deterministic, and correct even across a DSO boundary. There is no -fvisibility=hidden, and Windows sets WINDOWS_EXPORT_ALL_SYMBOLS.

  • symbol_base::operator== compares m_name; operator< uses it as the tiebreak (include/numsim_cas/core/symbol_base.h). Two names whose per-char hashes collide no longer merge.

  • relation::operator< (include/numsim_cas/core/assumptions.h) orders by the operands themselves instead of their hashes, so colliding relations stay distinct in relation::set.

Not changed: the hash discriminator

The issue also proposes mixing a domain tag into symbol_base::update_hash_value. I implemented it and measured: 43 existing tests fail, all print-order flips (y*pow(x,2)*z*X vs pow(x,2)*y*z*X) — same factors, different hash-driven order. Symbol hashes drive operator<, which drives n-ary print order, and many tests pin that output.

It is also not needed for correctness: the hash is a fast-reject and an ordering key, not an identity. With the type discrimination above, a hash tie between a scalar and a tensor symbol is a benign collision — std::map orders through operator<, which is now total and UB-free. I left the hash alone rather than rewrite 43 expected-output strings; changing print order deserves its own decision.

Tests

  • SymbolIdentity.SameNameAcrossDomainsIsDistinct — cross-domain == is false and the order is total.
  • SymbolIdentity.EvaluatorKeepsBothDomainBindingsevaluator_base keys one std::map<expression_holder<expression>, std::any>, so both domains share it; binding a scalar x and a tensor x must keep both values.
  • AssumptionFixture.RelationSetKeepsDistinctOperandsrelation<> has no users anywhere, so its operator< was never instantiated; this test compiles the changed code and pins that distinct relations stay distinct. It cannot demonstrate the collision fix (no way to force a hash collision through the public API).

Negative control (revert expression.cpp only, keep the tests):

Debug Release
SameNameAcrossDomainsIsDistinct abort in equals_same_type SIGSEGV
EvaluatorKeepsBothDomainBindings abort in less_than_same_type SIGSEGV

Both pass with the fix.

Suite: Debug 2349/2349, Release 2349/2349 (gcc-14, -Werror minus the pedantic / deprecated-declarations warnings already on main). clang-format-18 clean.

Not fixed (reported)

Overlap

Touches symbol_base.h (#431 adds the hash reset in update_hash_value) and the comparison operators (#436 removes their noexcept). Hunks are kept minimal and disjoint from both.

Node ids index each domain's own type list, so a scalar and a tensor
symbol of the same name tie on id and on the name-only hash. Comparison
then downcast to the wrong type: abort in Debug, SIGSEGV in Release.

- expression::operator==/< compare the dynamic type once id and hash tie,
  before the same-type downcast.
- symbol_base::operator== compares the name, operator< tiebreaks on it,
  so per-char hash collisions no longer merge distinct symbols.
- relation::operator< orders by its operands rather than their hashes,
  so colliding relations stay distinct members of relation::set.

Signed-off-by: petlenz <peterlenz89.pl@gmail.com>
…sert

- Drop the relation ordering comment that still described hash ordering.
- EvaluatorKeepsBothDomainBindings now asserts both entries coexist under
  evaluator_base's keying, not just that the scalar value survived.
- tensor_scalar_mul::like_term_of asserts the downcast like n_ary_tree does.

Signed-off-by: petlenz <peterlenz89.pl@gmail.com>
@petlenz
petlenz merged commit 0d06b45 into main Sep 16, 2026
27 checks passed
@petlenz
petlenz deleted the fix-348-symbol-identity branch September 16, 2026 22:21
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.

Scalar and tensor symbols with the same name collide (name-only hash, per-domain ids): assert abort in debug, UB with NDEBUG

1 participant