Skip to content

[epic] Canonical form + hash-consing: make expression identity structural, not conventional #379

Description

@petlenz

[epic] Canonical form + hash-consing: make expression identity structural, not conventional

Problem

Expression identity is currently decided ad hoc at every call site: deep operator== here, raw hash_value() there, pointer comparison elsewhere. The July 2026 review showed this is the library's worst bug class — #339, #340, #341, #342, #343, #347, #371 are all instances of "two places disagree about when expressions are the same". Fixing those issues repairs the sites we found; it does not prevent the next one, because the architecture allows every new dispatcher to re-decide identity.

There is also no defined canonical form and no user-callable simplify(): normalization happens implicitly at construction, differently per operator, so the same mathematical expression can exist in several structural shapes (#184 was an instance; the ((x+y)+x)+x → {x, y, 2*x} state behind #347 is another).

Goal

One authoritative answer to "are these expressions identical?", O(1), impossible to bypass.

Proposal: hash-consed unique table

  • A per-process (later: per-context) intern table maps structural key → shared_ptr<node>. All node construction goes through make_expression<T>(...), which canonicalizes children order (existing operator<), then interns.
  • After interning, identity is pointer equality. expression::operator== becomes this == &other; the entire hash-as-identity class (Raw hash_value() equality used as identity in simplifiers — X+pow(X,2)→2*pow(X,2), X+2*X→4*X, X*(2*X) drops the 2, sin(a)−sin(b)→0 #340) becomes unrepresentable.
  • The coefficient-blind hash design stays as the bucket key for like-term merging; it no longer doubles as identity anywhere.
  • Prerequisite: nodes must be immutable after construction (see the thread-safety epic — annotations are the hard part; either fold annotations into the intern key or move them to a side table keyed by node).

Scope / tasks

Acceptance criteria

Related

#339, #340, #341, #342, #343, #347, #371, #184 (closed), #95, #104, #262.

How to fix (implementation sketch)

Phase 0 — tactical ground truth. Land #339#343, #347, #371 with their lock-in tables. These tests define the identity contract the intern table must reproduce.

Phase 1 — immutability prerequisite. Hashes computed eagerly in node constructors (children are already constructed); annotations frozen at construction or moved to a side table (shared work with the thread-safety epic).

Phase 2 — intern table. In make_expression<T>(args...): build the node, derive the intern key = (domain tag, node type id, ordered child node pointers, payload: coefficient scalar_number, index sequences, symbol name, constant value bits). Look up in a std::unordered_map<key, std::weak_ptr<node>> guarded by a mutex (sharded if contended); return the existing node or insert. Expired entries cleaned lazily on collision/insert.

Phase 3 — identity switch. expression::operator== → pointer equality; operator< → stable total order on (hash, pointer) for map determinism, keeping the existing hash-based print order. Delete per-node operator== overrides.

Phase 4 — sweep + guard. Remove now-redundant hash-equality shortcuts; add a clang-tidy/grep CI check that hash_value() == appears only inside the intern implementation.

Tests

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestepicMulti-issue initiative

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions