Version: 1.0.0 Branch:
move_to_virtualLanguage: C++23
- Introduction & Motivation
- Foundational Concepts
- Core Layer
- Scalar Domain
- Tensor Domain
- Tensor-to-Scalar Domain
- Simplification Engine
- Differentiation System
- Evaluation System
- Projection Tensor Algebra
- Hash System & Canonical Ordering
- Cross-Domain Interactions
- Build System & Dependencies
- File Reference
- Glossary
numsim-cas is a Computer Algebra System (CAS) implemented in C++23, designed specifically for symbolic tensor calculus in the context of computational mechanics. It enables:
- Building symbolic expressions involving scalars and tensors
- Automatic algebraic simplification (constant folding, like-term merging, identity laws)
- Symbolic differentiation (chain rule, product rule, Leibniz rule)
- Numerical evaluation using the tmech tensor library
- Projection tensor algebra (symmetric, deviatoric, volumetric, skew decompositions)
- Type safety: Three distinct expression domains prevent mixing incompatible types
- Immutability: Expression nodes are never modified after construction -- all transformations produce new nodes
- Eager simplification: Every operation simplifies immediately, producing canonical forms
- Extensibility: The tag_invoke CPO pattern allows adding new operations without modifying existing code
- Performance: Shared pointer semantics enable subexpression sharing; hash-based lookup enables O(1) simplification checks
The library models three mathematical domains connected by typed bridges:
┌──────────────────────────────────────────────────────────────┐
│ Core Layer │
│ expression, expression_holder, visitor_base, n_ary_tree, │
│ domain_traits, tag_invoke, hash_functions, scalar_number │
└────────────────────┬───────────────┬──────────────┬──────────┘
│ │ │
┌───────────▼──┐ ┌───────▼───────┐ ┌──▼────────────────┐
│ Scalar │ │ Tensor │ │ Tensor-to-Scalar │
│ Domain │ │ Domain │ │ Domain │
│ │ │ │ │ │
│ x, y, z │ │ A, B, C │ │ trace(A), det(A) │
│ sin, cos, pow │ │ sym, dev, vol │ │ norm(A), dot(A,B) │
│ +, -, *, / │ │ inner, outer │ │ +, -, *, pow │
└───────┬───────┘ └───────┬───────┘ └──┬────────────────┘
│ │ │
└────────connects───┘──────────────┘
via scalar_mul, scalar_wrapper,
tensor_to_scalar_with_tensor_mul
The library targets researchers and engineers in computational mechanics who need to:
- Derive material tangent tensors symbolically
- Implement constitutive models with automatic differentiation
- Verify hand-derived formulas against symbolic computation
- Generate code for finite element solvers
Every symbolic expression is represented as a Directed Acyclic Graph (DAG) of nodes. Each node is either:
- A leaf (variable, constant, zero, one)
- A unary operation (negation, sin, cos, transpose, ...)
- A binary operation (pow, rational, scalar_mul, ...)
- An n-ary operation (add, mul -- variadic number of children)
Nodes are immutable after construction. Shared subexpressions are represented by multiple references to the same node (via shared_ptr), forming a DAG rather than a tree:
Expression: x^2 + 2*x + 1
DAG: add
/ | \
pow mul one
/\ /\
x 2 2 x ← same 'x' node shared
All expression nodes are accessed through expression_holder<Base>, a RAII wrapper around std::shared_ptr<node_type>:
expression_holder<scalar_expression> x = make_scalar_variable("x");
// Access the underlying node:
auto& node = x.get(); // returns scalar_expression&
auto& concrete = x.get<scalar>(); // downcast to concrete type
// Validity check:
if (x.is_valid()) { ... }
// Automatic operator dispatch:
auto f = x + x; // triggers tag_invoke(add_fn, x, x)The wrapper provides:
- RAII: Automatic memory management via shared_ptr
- Type safety:
expression_holder<scalar_expression>cannot hold a tensor node - Operator overloads:
+,-,*,/, unary-delegate to CPOs - Compound assignment:
+=,*=for in-place modification (creates new node)
Unlike variant-based CAS implementations, numsim-cas uses the virtual visitor pattern. Each expression domain defines a visitor base class with pure virtual operator() overloads for every node type:
// Generated from the node list macro:
class scalar_visitor_const_t {
public:
virtual void operator()(scalar const&) = 0;
virtual void operator()(scalar_zero const&) = 0;
virtual void operator()(scalar_one const&) = 0;
virtual void operator()(scalar_constant const&) = 0;
virtual void operator()(scalar_add const&) = 0;
virtual void operator()(scalar_mul const&) = 0;
virtual void operator()(scalar_pow const&) = 0;
// ... one per node type (21 total for scalar)
};Each node implements accept(visitor) via CRTP:
template <typename Base, typename Derived, typename... Types>
class visitable_impl : public visitable<Base, Types...> {
void accept(visitor_const<Types...>& v) const override {
v(static_cast<Derived const&>(*this)); // double dispatch
}
};This design was chosen over std::variant because:
- Adding new node types doesn't change existing visitor overload sets
- Virtual dispatch has predictable performance
- The visitor pattern is well-suited to the many-operations-few-types shape of CAS code
Operators (+, -, *, /) are implemented using the Customization Point Object (CPO) pattern based on tag_invoke:
// 1. Define a tag type:
struct add_fn {
template <class L, class R>
requires tag_invocable<add_fn, L&&, R&&>
constexpr auto operator()(L&& l, R&& r) const {
return tag_invoke(*this, std::forward<L>(l), std::forward<R>(r));
}
};
inline constexpr add_fn binary_add{};
// 2. Global operator delegates to CPO:
template <class L, class R>
requires cas_binary_op<L, R>
auto operator+(L&& l, R&& r) {
return detail::binary_add(std::forward<L>(l), std::forward<R>(r));
}
// 3. Each domain implements via ADL:
// In scalar_operators.h:
expression_holder<scalar_expression>
tag_invoke(add_fn, scalar_expr auto&& lhs, scalar_expr auto&& rhs) {
// ... simplification logic ...
}This pattern allows each domain to customize behavior for its own types without modifying the core operator infrastructure.
The domain_traits<Domain> template provides type-level information about each expression domain:
template <> struct domain_traits<scalar_expression> {
using expr_holder_t = expression_holder<scalar_expression>;
using add_type = scalar_add;
using mul_type = scalar_mul;
using pow_type = scalar_pow;
using negative_type = scalar_negative;
using zero_type = scalar_zero;
using one_type = scalar_one;
using constant_type = scalar_constant;
using symbol_type = scalar;
static expr_holder_t zero();
static expr_holder_t one();
static std::optional<scalar_number> try_numeric(expr_holder_t const&);
static expr_holder_t make_constant(scalar_number const&);
};Generic simplification algorithms are parameterized by these traits, enabling code reuse across domains.
expression is the root of the node hierarchy. Every node in every domain ultimately inherits from it.
Key members:
m_hash_value(mutable, lazily computed): Cached hash for O(1) comparison fast-pathm_assumption: Numeric assumptions (positive, negative, integer, etc.)
Key methods:
hash_value(): Returns cached hash, computing it on first call viaupdate_hash_value()id(): Returns a unique type ID per concrete node classoperator==: Hash-first comparison, then deep structural comparisonoperator<: Total ordering for canonical print output
Comparison protocol:
a == b ⟺ a.hash == b.hash AND a.equals_same_type(b)
a < b ⟺ a.hash < b.hash OR (a.hash == b.hash AND a.less_than_same_type(b))
Holds one child expression. Used for: negation, sin, cos, tan, transpose, inverse, etc.
template <typename ThisBase, typename ExprBase>
class unary_op : public ThisBase {
expression_holder<ExprBase> m_expr;
public:
auto const& expr() const { return m_expr; }
};Holds two child expressions. Used for: pow, scalar_mul, rational, tensor products, etc.
template <typename ThisBase, typename BaseLHS, typename BaseRHS>
class binary_op : public ThisBase {
expression_holder<BaseLHS> m_lhs;
expression_holder<BaseRHS> m_rhs;
public:
auto const& expr_lhs() const { return m_lhs; }
auto const& expr_rhs() const { return m_rhs; }
};Note: BaseLHS and BaseRHS can be different types, enabling cross-domain operations (e.g., tensor_scalar_mul has scalar_expression LHS and tensor_expression RHS).
Hash-map-based storage for commutative n-ary operations (addition, multiplication).
template <typename Base>
class n_ary_tree : public Base {
expr_ordered_map<expr_holder_t> m_symbol_map; // hash -> child expr
expr_holder_t m_coeff; // numeric coefficient
};Children are stored in an ordered map keyed by hash value. The coefficient is stored separately and excluded from the hash. This enables 3*x and 5*x to have the same hash as x, which is critical for like-term merging.
Hash computation:
- Start with the node type ID
- Collect all child hashes into a buffer
- Sort the buffer (commutativity:
a+bandb+ahash the same) hash_combineeach sorted hash into the seed
Vector-based storage for non-commutative n-ary operations (tensor products, inner products).
template <typename Base>
class n_ary_vector : public Base {
expr_vector<expr_holder_t> m_data; // ordered list
expr_holder_t m_coeff; // coefficient
};Unlike n_ary_tree, the hash does NOT sort children -- order matters.
Base for leaf nodes representing named variables.
template <typename BaseExpr>
class symbol_base : public BaseExpr {
std::string m_name;
public:
auto const& name() const { return m_name; }
};Hash is based solely on the variable name.
A type-erasing numeric wrapper:
class scalar_number {
std::variant<std::int64_t, double, std::complex<double>> v_;
public:
// Arithmetic with automatic type promotion
friend scalar_number operator+(scalar_number, scalar_number);
friend scalar_number operator*(scalar_number, scalar_number);
// Comparison
friend bool operator==(scalar_number, scalar_number);
friend bool operator<(scalar_number, scalar_number);
};Integers stay exact (int64_t), promoted to double when mixed with floats, and to complex<double> when mixed with complex.
The assumption system attaches metadata to expressions:
// Numeric assumptions
assume(x, positive{});
assume(x, integer{});
// Queries
bool is_positive(expr);
bool is_negative(expr);
bool is_nonnegative(expr);
// Implications: assume(x, positive{}) also implies nonzero{} and nonnegative{}Assumptions are used by construction-time simplification:
abs(x)withpositiveassumption returnsxsign(x)withpositiveassumption returns1sqrt(pow(x, 2))withnonnegativeassumption returnsx
The sequence class represents ordered index lists for tensor operations:
sequence s{3, 4}; // User input: 1-based indices 3 and 4
// Internally stored as {2, 3} (0-based)
// Operations:
auto c = concat(seq1, seq2); // concatenate
auto [a, b] = split(seq, n); // split at position n
auto p = permute(seq, perm); // reorder by permutation
auto inv = invert_perm(perm); // inverse permutationConvention: All public APIs accept 1-based indices (mathematical notation). Internal storage is 0-based (C++ convention).
| Node | Base | Description |
|---|---|---|
scalar |
symbol_base |
Named variable (x, y, z) |
scalar_zero |
leaf | Additive identity (0) |
scalar_one |
leaf | Multiplicative identity (1) |
scalar_constant |
leaf | Numeric constant (wraps scalar_number) |
scalar_add |
n_ary_tree |
Commutative addition (coefficient + children) |
scalar_mul |
n_ary_tree |
Commutative multiplication (coefficient * children) |
scalar_negative |
unary_op |
Unary negation (-x) |
scalar_pow |
binary_op |
Exponentiation (x^n) |
scalar_rational |
binary_op |
Rational expression (numerator/denominator) |
scalar_sin |
unary_op |
Sine function |
scalar_cos |
unary_op |
Cosine function |
scalar_tan |
unary_op |
Tangent function |
scalar_asin |
unary_op |
Arcsine function |
scalar_acos |
unary_op |
Arccosine function |
scalar_atan |
unary_op |
Arctangent function |
scalar_sqrt |
unary_op |
Square root |
scalar_exp |
unary_op |
Exponential function |
scalar_log |
unary_op |
Natural logarithm |
scalar_abs |
unary_op |
Absolute value |
scalar_sign |
unary_op |
Sign function (-1, 0, or 1) |
scalar_named_expression |
unary_op |
Named wrapper (user-defined functions) |
using namespace numsim::cas;
// Variables
auto [x, y, z] = make_scalar_variable("x", "y", "z");
// Constants
auto [_1, _2, _3] = make_scalar_constant(1, 2, 3);
// Special constants
auto zero = get_scalar_zero();
auto one = get_scalar_one();
// Expressions (simplify automatically)
auto f = pow(x, _2) + _2 * x * y + pow(y, _2);
auto g = sin(x) + cos(y);
auto h = exp(log(x)); // simplifies to x at construction time// Arithmetic (delegates to tag_invoke CPOs)
auto sum = f + g; // addition
auto diff = f - g; // subtraction (implemented as f + (-g))
auto prod = f * g; // multiplication
auto quot = f / g; // division (implemented as f * pow(g, -1))
auto neg = -f; // negation
// Functions (construction-time simplification)
using std::sin, std::cos, std::pow, std::exp, std::log, std::sqrt;
auto s = sin(x);
auto c = cos(x);
auto p = pow(x, _2);
auto e = exp(x);
auto l = log(x);
auto r = sqrt(x);
// Differentiation
auto df_dx = diff(f, x);
// Substitution
auto result = substitute(f, x, _2 + y); // replace x with 2+y
// Evaluation
scalar_evaluator<double> ev;
ev.set(x, 3.0);
ev.set(y, 2.0);
double value = ev.apply(f);An n_ary_tree-based add node stores:
scalar_add {
coefficient: scalar_constant(5) // the numeric part
children: {
hash(x) -> x, // variable terms
hash(y) -> scalar_mul{coeff: 3, children: {y}}, // 3*y
hash(pow(z,2)) -> pow(z, 2) // non-linear terms
}
}
This represents 5 + x + 3*y + z^2. The hash-map enables O(1) lookup when adding a new term -- if it matches an existing child's hash, the coefficients are merged.
| Node | Base | Description |
|---|---|---|
tensor |
symbol_base |
Named tensor variable (A, B, C) with dim and rank |
tensor_zero |
leaf | Zero tensor of given dim and rank |
identity_tensor |
leaf | Identity tensor (I for rank 2, I_ijkl for rank 4) |
kronecker_delta |
leaf | Kronecker delta (always rank 2) |
tensor_projector |
leaf | Projection tensor (P_sym, P_dev, P_vol, P_skew) |
tensor_add |
n_ary_tree |
Commutative tensor addition |
tensor_mul |
n_ary_vector |
Non-commutative tensor product (A*B) |
tensor_pow |
binary_op |
Tensor power (A^n for integer n) |
tensor_negative |
unary_op |
Tensor negation (-A) |
tensor_scalar_mul |
binary_op |
Scalar-tensor product (s*A) |
tensor_inv |
unary_op |
Matrix inverse (A^{-1}) |
inner_product_wrapper |
binary with indices | Index contraction (A:B) |
outer_product_wrapper |
binary with indices | Tensor product (A ⊗ B) |
basis_change_imp |
unary with indices | Index permutation (transpose) |
simple_outer_product |
n-ary | Iterated outer product |
tensor_to_scalar_with_tensor_mul |
binary | Cross-domain: T2S * Tensor |
Every tensor expression carries:
- dim: Spatial dimension (1, 2, or 3)
- rank: Tensor rank (0=scalar, 1=vector, 2=matrix, 4=fourth-order, etc.)
- space (optional): Algebraic space classification
auto [A] = make_tensor_variable(std::tuple{"A", 3, 2});
// A is a rank-2 tensor in 3D (i.e., a 3x3 matrix)
A.get().dim(); // 3
A.get().rank(); // 2
A.get().space(); // std::nullopt (general, no constraints)The tensor_space classifies tensors by symmetry and trace properties:
struct tensor_space {
std::variant<General, Symmetric, Skew, Young> perm; // permutation symmetry
std::variant<AnyTraceTag, VolumetricTag, DeviatoricTag,
HarmonicTag, PartialTraceTag> trace; // trace constraint
};Permutation spaces:
General: No symmetry constraintsSymmetric: A_ij = A_ji (with specific index permutations)Skew: A_ij = -A_jiYoung: Young tableau symmetry (for higher ranks)
Trace spaces:
AnyTraceTag: No trace constraintVolumetricTag: Proportional to identity (A = (1/d)tr(A)*I)DeviatoricTag: Trace-free (tr(A) = 0)HarmonicTag: Both symmetric and trace-freePartialTraceTag: Partial traces vanish
Space join semantics (used when adding tensors):
Symmetric + Symmetric -> Symmetric(preserved)Symmetric + General -> General(widened)Volumetric + Deviatoric -> Symmetric(combined via projectors)- Incompatible spaces -> cleared (no constraint)
using namespace numsim::cas;
// Variables
auto [A, B] = make_tensor_variable(
std::tuple{"A", 3, 2}, std::tuple{"B", 3, 2});
auto [C] = make_tensor_variable(std::tuple{"C", 3, 4}); // rank-4
// Special tensors
auto I = make_expression<identity_tensor>(3, 2); // 3D rank-2 identity
auto delta = make_expression<kronecker_delta>(3); // Kronecker delta
auto Z = make_expression<tensor_zero>(3, 2); // Zero tensor
// Arithmetic
auto sum = A + B; // tensor addition
auto diff = A - B; // tensor subtraction
auto neg = -A; // negation
auto scale = x * A; // scalar-tensor multiplication
auto power = pow(A, _2); // tensor power (A*A via contraction)using seq = sequence;
// Inner product (contraction)
auto C = inner_product(A, seq{2}, B, seq{1}); // A_ij * B_jk = C_ik
// Full contraction (double inner product)
auto D = inner_product(A, seq{1,2}, B, seq{1,2}); // A:B (scalar result in tensor domain)
// Outer product
auto E = otimes(A, B); // A_ij * B_kl = E_ijkl (rank 4)
auto F = otimesu(A, B); // A_ik * B_jl (upper product)
auto G = otimesl(A, B); // A_il * B_jk (lower product)
// Index permutation / transpose
auto At = trans(A); // A^T (equivalent to basis_change with {2,1})
auto Ap = permute_indices(C, seq{2,1,4,3}); // arbitrary index permutation
// Matrix inverse
auto Ainv = inv(A); // A^{-1}// Projections (construction-time simplification)
auto As = sym(A); // symmetric part: P_sym : A
auto Ad = dev(A); // deviatoric part: P_devi : A
auto Av = vol(A); // volumetric part: P_vol : A
auto Ak = skew(A); // skew part: P_skew : A
// Standalone projectors (rank-4 tensors)
auto Ps = P_sym(3); // symmetric projector in 3D
auto Pd = P_devi(3); // deviatoric projector
auto Pv = P_vol(3); // volumetric projector
auto Pk = P_skew(3); // skew projector
// Projector algebra (at construction time):
dev(dev(A)); // -> dev(A) (idempotence)
vol(dev(A)); // -> 0 (orthogonality)
sym(skew(A)); // -> 0 (orthogonality)
vol(A) + dev(A); // -> sym(A) (decomposition)
sym(A) + skew(A); // -> A (complete decomposition)
// Assumptions enable further simplification:
assume_symmetric(A);
sym(A); // -> A (already symmetric)
skew(A); // -> 0 (symmetric => no skew part)The tensor-to-scalar domain bridges tensor expressions with scalar algebra. It represents operations that take tensors as input and produce scalars:
trace(A) : rank-2 tensor -> scalar
det(A) : rank-2 tensor -> scalar
norm(A) : rank-2 tensor -> scalar
dot(A, B) : two tensors -> scalar (dot product)
These scalar results can then participate in scalar arithmetic and be used as coefficients in tensor expressions.
| Node | Base | Description |
|---|---|---|
tensor_to_scalar_zero |
leaf | Additive identity (0) |
tensor_to_scalar_one |
leaf | Multiplicative identity (1) |
tensor_to_scalar_scalar_wrapper |
unary_op |
Wraps a scalar_expression for use in T2S |
tensor_trace |
unary_op<..., tensor_expression> |
Trace operation |
tensor_dot |
unary_op<..., tensor_expression> |
Self dot product (A:A) |
tensor_det |
unary_op<..., tensor_expression> |
Determinant |
tensor_norm |
unary_op<..., tensor_expression> |
Frobenius norm (sqrt(A:A)) |
tensor_to_scalar_add |
n_ary_tree |
Addition |
tensor_to_scalar_mul |
n_ary_tree |
Multiplication |
tensor_to_scalar_pow |
binary_op |
Power |
tensor_to_scalar_negative |
unary_op |
Negation |
tensor_to_scalar_log |
unary_op |
Natural logarithm |
tensor_inner_product_to_scalar |
binary with indices | Indexed inner product -> scalar |
using namespace numsim::cas;
auto [A, B] = make_tensor_variable(
std::tuple{"A", 3, 2}, std::tuple{"B", 3, 2});
auto [x] = make_scalar_variable("x");
// T2S functions
auto tr = trace(A); // tr(A)
auto d = det(A); // det(A)
auto n = norm(A); // ||A|| (Frobenius norm)
auto dp = dot(A); // A:A (self dot product)
auto ip = dot_product(A, seq{1,2}, B, seq{1,2}); // A:B
// Mixed arithmetic (T2S with scalars)
auto f = x * trace(A) + det(B); // scalar * T2S + T2S
auto g = pow(trace(A), 2); // T2S power
// Construction-time simplifications
trace(make_expression<tensor_zero>(3, 2)); // -> 0
trace(make_expression<kronecker_delta>(3)); // -> 3 (dimension)
det(make_expression<kronecker_delta>(3)); // -> 1
norm(make_expression<tensor_zero>(3, 2)); // -> 0The tensor_to_scalar_scalar_wrapper node wraps a plain scalar expression so it can participate in T2S arithmetic:
// When you write:
auto f = x + trace(A);
// Internally, x is wrapped:
// tensor_to_scalar_add {
// children: {
// tensor_to_scalar_scalar_wrapper(x),
// tensor_trace(A)
// }
// }This wrapping happens automatically in the operator overloads. The simplifiers know how to unwrap scalars for numeric operations and re-wrap the results.
Simplification happens immediately when an operation is performed. The call chain for a + b:
operator+(a, b)
└─> detail::binary_add(a, b) // CPO dispatch
└─> tag_invoke(add_fn, a, b) // domain-specific
└─> add_base(a, b) // dispatcher
├─ Quick checks: a+0->a, a+(-a)->0
├─ a.accept(add_base) // visitor dispatch on LHS type
│ └─> add_base.dispatch(concrete_lhs)
│ └─ Creates specialized visitor for LHS type
│ └─ rhs.accept(specialized_visitor)
│ └─> specialized_visitor.dispatch(concrete_rhs)
│ └─ Apply specific simplification rule
└─ If no special rule: get_default() // build new add node
add_base (main dispatcher)
│
├── dispatch(scalar_zero) → return rhs (additive identity)
├── dispatch(scalar_one) → create one_add visitor
├── dispatch(scalar_constant) → create constant_add visitor
├── dispatch(scalar_add) → create n_ary_add visitor (flatten)
├── dispatch(scalar_mul) → create n_ary_mul_add visitor
├── dispatch(scalar) → create symbol_add visitor
├── dispatch(scalar_negative) → create negative_add visitor
└── dispatch(other) → swap operands if add, else get_default()
Each specialized visitor then dispatches on the RHS type:
constant_add (LHS is constant)
│
├── dispatch(scalar_constant) → fold: c1 + c2
├── dispatch(scalar_zero) → return c1
├── dispatch(scalar_one) → c1 + 1
├── dispatch(scalar_add) → merge constant into add's coefficient
├── dispatch(scalar_negative) → c1 + (-x) = c1 - x
└── dispatch(other) → build new add
The core simplifier algorithms in core/simplifier/ are parameterized by domain traits:
template <typename Traits>
class add_dispatch {
using expr_holder_t = typename Traits::expr_holder_t;
using add_type = typename Traits::add_type;
using mul_type = typename Traits::mul_type;
using zero_type = typename Traits::zero_type;
using constant_type = typename Traits::constant_type;
expr_holder_t get_default() {
// Build new add node using domain types
auto result = make_expression<add_type>(...);
auto& tree = result.template get<add_type>();
// ... populate tree ...
return result;
}
};Each domain provides thin wrapper classes:
// Scalar domain (scalar_simplifier_add.h):
class scalar_add_dispatch : public scalar_visitor_return_expr_t,
public add_dispatch<domain_traits<scalar_expression>> {
// Uses the node list macro to declare dispatch methods
};Addition:
| Rule | Example |
|---|---|
| Additive identity | x + 0 -> x |
| Same expression | x + x -> 2*x |
| Constant folding | 3 + 5 -> 8 |
| Flattening | (a+b) + c -> a+b+c |
| Coefficient merge | 3*x + 5*x -> 8*x |
| Cancellation | x + (-x) -> 0 |
| Negative merge | (-a) + (-b) -> -(a+b) |
Multiplication:
| Rule | Example |
|---|---|
| Multiplicative identity | x * 1 -> x |
| Annihilator | x * 0 -> 0 |
| Same expression | x * x -> pow(x, 2) |
| Constant folding | 3 * 5 -> 15 |
| Flattening | (a*b) * c -> a*b*c |
| Power merge | x * pow(x, n) -> pow(x, n+1) |
| Coefficient extract | (3*x) * (5*y) -> 15*x*y |
Exponentiation:
| Rule | Example |
|---|---|
| Identity | x^1 -> x |
| Zero | x^0 -> 1 |
| Power of power | pow(pow(x, a), b) -> pow(x, a*b) |
| Power of product | pow(x*y, n) -> pow(x,n)*pow(y,n) (integer n) |
| Inverse cancellation | x * pow(x, -1) -> 1 |
| Division | x / y -> x * pow(y, -1) |
Construction-time (functions):
| Rule | Example |
|---|---|
| sin(0) | -> 0 |
| cos(0) | -> 1 |
| tan(0) | -> 0 |
| sin(asin(x)) | -> x |
| cos(acos(x)) | -> x |
| exp(0) | -> 1 |
| exp(log(x)) | -> x |
| log(1) | -> 0 |
| log(exp(x)) | -> x |
| sqrt(0) | -> 0 |
| sqrt(1) | -> 1 |
| abs(x) if x>0 | -> x |
| sign(x) if x>0 | -> 1 |
Differentiation is implemented as a visitor that walks the expression tree and applies derivative rules. Each domain has its own differentiation visitor:
scalar_differentiation: d(scalar)/d(scalar) -> scalartensor_differentiation: d(tensor)/d(tensor) -> tensor (higher rank)tensor_to_scalar_differentiation: d(T2S)/d(tensor) -> tensor
// Scalar differentiation
auto df = diff(f, x); // d(f)/d(x)
// Tensor differentiation
auto dA = diff(expr, A); // d(expr)/d(A), result rank = expr.rank + A.rank
// T2S to tensor differentiation
auto dT = diff(trace(A), A); // d(trace(A))/d(A) = IThe diff CPO dispatches via tag_invoke:
struct diff_fn {
template <class ExprBase, class ArgBase>
auto operator()(expression_holder<ExprBase> const& expr,
expression_holder<ArgBase> const& arg) const;
};
inline constexpr diff_fn diff{};| Expression | Derivative |
|---|---|
| constant, zero, one | 0 |
| x (matches arg) | 1 |
| x (different) | 0 |
| u + v | du + dv |
| u * v | u*dv + du*v (product rule) |
| -u | -du |
| u^n (constant n) | n * u^(n-1) * du (chain rule) |
| u^v (general) | u^(v-1) * (v' * log(u) * u + v * u') |
| sin(u) | cos(u) * du |
| cos(u) | -sin(u) * du |
| tan(u) | (1/cos(u)^2) * du |
| asin(u) | du / sqrt(1 - u^2) |
| acos(u) | -du / sqrt(1 - u^2) |
| atan(u) | du / (1 + u^2) |
| exp(u) | exp(u) * du |
| log(u) | du / u |
| sqrt(u) | du / (2*sqrt(u)) |
| abs(u) | u/abs(u) * du |
| sign(u) | 0 (distributional) |
Tensor differentiation produces higher-rank tensors:
| Expression | Derivative d/dA | Rank Change |
|---|---|---|
| A (matches arg) | I or P_space | +rank(A) |
| B (different) | 0 | +rank(A) |
| A + B | dA/dA + dB/dA | same |
| s * A | s * dA/dA | same |
| -A | -dA/dA | same |
| A^n | sum of A^(i-1) * dA/dA * A^(n-i) | same |
| inv(A) | -inv(A) * dA/dA * inv(A) | same |
| trans(A) | trans(dA/dA) | same |
| A : B | dA/dA : B + A : dB/dA | depends |
Space-aware derivatives: When the differentiation variable has a tensor space, the identity tensor is replaced by the appropriate projector:
d(symmetric_A)/d(symmetric_A) = P_sym(not I)d(deviatoric_A)/d(deviatoric_A) = P_devi(not I)
| Expression | Derivative d/dA | Result |
|---|---|---|
| trace(A) | I | identity tensor |
| det(A) | det(A) * inv(A)^T | cofactor matrix |
| dot(A) (A:A) | 2*A | tensor |
| norm(A) | A / norm(A) | tensor |
Evaluation transforms symbolic expressions into numeric values using the visitor pattern:
// Scalar evaluation
scalar_evaluator<double> sev;
sev.set(x, 3.0);
sev.set(y, 2.0);
double result = sev.apply(f);
// Tensor evaluation
tensor_evaluator<double> tev;
auto A_data = make_tensor_data<double>(3, 2); // 3D rank-2
A_data->data() = some_tmech_tensor;
tev.set(A, std::move(A_data));
auto result_data = tev.apply(expr);
// T2S evaluation
tensor_to_scalar_evaluator<double> t2sev;
t2sev.set(A, A_data);
double trace_val = t2sev.apply(trace(A));Tensor evaluation produces tensor_data_base<ValueType> objects that wrap tmech::tensor<ValueType, Dim, Rank>:
template <typename ValueType, std::size_t Dim, std::size_t Rank>
class tensor_data : public tensor_data_base<ValueType> {
tmech::tensor<ValueType, Dim, Rank> m_data;
};Since Dim and Rank are template parameters but can vary at runtime, the evaluator uses a dispatch mechanism (tensor_data_eval) that tries all valid Dim/Rank combinations:
// Pseudo-code for dispatch:
if (dim == 1 && rank == 2) evaluate<1, 2>(...);
else if (dim == 2 && rank == 2) evaluate<2, 2>(...);
else if (dim == 3 && rank == 2) evaluate<3, 2>(...);
// ... etc for rank 1, 3, 4The tensor evaluator includes optimized paths for common patterns:
- Projector contractions:
P:Awhere P is a known projector and A is rank-2 directly callstmech::sym(),tmech::dev(),tmech::vol(), ortmech::skew()instead of full rank-4 contraction - Identity tensor: Rank-2 uses
tmech::eye, rank-4 usestmech::otimesu(I, I)(minor identity) - Zero tensor: Returns zero-initialized data without computation
In continuum mechanics, tensors are often decomposed into symmetric, deviatoric, volumetric, and skew-symmetric parts. This decomposition is performed by fourth-order projection tensors:
- P_sym: Projects onto symmetric part:
(P_sym:A)_ij = (A_ij + A_ji)/2 - P_skew: Projects onto skew part:
(P_skew:A)_ij = (A_ij - A_ji)/2 - P_vol: Projects onto volumetric part:
(P_vol:A)_ij = (1/d)*tr(A)*delta_ij - P_devi: Projects onto deviatoric part:
P_devi = P_sym - P_vol
These satisfy important algebraic rules that the CAS exploits for simplification.
class tensor_projector {
std::size_t m_dim; // spatial dimension
std::size_t m_acts_on_rank; // rank of input (2 for rank-2 tensors)
tensor_space m_space; // identifies which projector
};Rank of the projector = 2 * acts_on_rank (e.g., rank 4 for rank-2 projectors).
Factory functions:
P_sym(dim) // Symmetric projector
P_skew(dim) // Skew projector
P_vol(dim) // Volumetric projector
P_devi(dim) // Deviatoric projector
P_harm(dim, rank) // Harmonic projectorWhen two projectors are contracted (P_a : P_b), the result is determined by:
enum class ContractionRule { Idempotent, Zero, LhsSubspace, RhsSubspace };
Contraction table (P_a : P_b -> result):
| P_a \ P_b | Sym | Skew | Vol | Dev |
|---|---|---|---|---|
| Sym | Sym (idem.) | 0 | Vol (rhs_sub) | Dev (rhs_sub) |
| Skew | 0 | Skew (idem.) | 0 | 0 |
| Vol | Vol (lhs_sub) | 0 | Vol (idem.) | 0 |
| Dev | Dev (lhs_sub) | 0 | 0 | Dev (idem.) |
- Idempotent: P:P -> P (projecting twice is same as once)
- Zero: Orthogonal projectors annihilate each other
- LhsSubspace: LHS is a subspace of RHS, result is LHS
- RhsSubspace: RHS is a subspace of LHS, result is RHS
When two projected expressions with the same argument are added:
| P_a(X) + P_b(X) | Result |
|---|---|
| Vol(X) + Dev(X) | Sym(X) |
| Sym(X) + Skew(X) | X (identity) |
Applied when dev(), sym(), vol(), skew() are called:
dev(dev(A)) -> dev(A) // idempotent
sym(sym(A)) -> sym(A) // idempotent
vol(dev(A)) -> 0 // orthogonal
sym(skew(A)) -> 0 // orthogonal
dev(vol(A)) -> 0 // orthogonal
vol(sym(A)) -> vol(A) // subspace
dev(sym(A)) -> dev(A) // subspace
The tensor_projector_simplifier visitor applies these rules post-construction:
- Inner product handler: Detects
P : (P' : X)patterns and applies contraction rules - Addition handler: Groups projector contractions by argument hash, applies addition rules
- Standalone contraction: Detects
P : P'(no argument) and simplifies
Every expression node has a hash value computed from its structure:
// For unary_op (sin, cos, neg, etc.):
hash = hash_combine(0, type_id)
hash = hash_combine(hash, child.hash)
// For binary_op (pow, scalar_mul, etc.):
hash = hash_combine(0, type_id)
hash = hash_combine(hash, lhs.hash)
hash = hash_combine(hash, rhs.hash)
// For n_ary_tree (add, mul):
hash = hash_combine(0, type_id)
sort(child_hashes) // commutativity!
for each sorted_hash:
hash = hash_combine(hash, sorted_hash)
// NOTE: coefficient is NOT included in hash
// For symbols:
hash = hash_combine(0, name_hash)The hash of n_ary_tree and certain binary_op nodes deliberately excludes numeric coefficients:
hash(3*x) == hash(x) // scalar_mul coefficient excluded
hash(x^2) == hash(x) // if exponent is constant
hash(5 + x + y) == hash(x + y) // add coefficient excluded
This enables the simplifier to:
- Look up
xin a hash-map of existing terms - Find
3*xat the same hash slot - Merge:
3*x + 5*x -> 8*x
Without this rule, 3*x and 5*x would hash differently and couldn't be merged in O(1).
Print output uses operator< which compares hash values:
a < b ⟺ hash(a) < hash(b) OR (hash(a) == hash(b) AND structural_less(a, b))
This means:
- Terms in a sum are printed in hash order (deterministic but not alphabetical)
- Factors in a product are printed in hash order
- Hash order is stable across runs (same input -> same output)
template <typename T>
void hash_combine(std::size_t& seed, const T& value) {
seed ^= static_cast<std::size_t>(value) +
static_cast<std::size_t>(0x9e3779b9) +
(seed << 6) + (seed >> 2);
}This is the Boost hash_combine algorithm using the golden ratio constant 0x9e3779b9 ≈ 2^32 / φ. It provides good distribution with low collision rates.
Scalar-tensor multiplication:
auto expr = x * A; // creates tensor_scalar_mul(x, A)The tensor_scalar_mul node has scalar_expression LHS and tensor_expression RHS.
Hash rule: If the scalar is a constant, the hash equals the tensor's hash. This enables 3*A + 5*A -> 8*A.
Wrapping:
When a scalar appears in a T2S context, it's wrapped in tensor_to_scalar_scalar_wrapper:
auto f = x + trace(A);
// Internally: t2s_add(t2s_scalar_wrapper(x), tensor_trace(A))Unwrapping in simplifiers: When two scalar_wrappers are added, the simplifier unwraps them, performs scalar addition, and re-wraps:
scalar_wrapper(x) + scalar_wrapper(y)
// Unwrap: x, y
// Scalar add: x + y
// Re-wrap: scalar_wrapper(x + y)T2S functions on tensors:
auto tr = trace(A); // tensor -> T2S
auto d = det(A); // tensor -> T2ST2S * Tensor:
auto expr = trace(A) * B; // creates tensor_to_scalar_with_tensor_mulDifferentiation bridge:
auto dA = diff(trace(A), A); // T2S differentiated w.r.t. tensor -> tensor resultThe promote_expr_fn CPO handles type promotion when different domains interact:
// scalar + T2S -> both become T2S (scalar is wrapped)
// T2S * tensor -> result is tensor (via tensor_to_scalar_with_tensor_mul)numsim-cas/
├── CMakeLists.txt # Main build file (207 LOC)
├── include/
│ └── numsim_cas/ # Public headers (192 files)
├── src/
│ └── numsim_cas/ # Implementation files (38 files)
├── tests/
│ ├── CMakeLists.txt # Test build (53 LOC)
│ └── *.h / main.cpp # Test sources (22 files)
├── examples/
│ └── CMakeLists.txt # Example build
└── benchmarks/
└── poly_verse_variant/ # Benchmark (WIP)
# Configure
cmake -B build -DCMAKE_BUILD_TYPE=Release
# Build
cmake --build build
# Test
ctest --test-dir build --output-on-failure
# With sanitizers
cmake -B build -DNUMSIM_CAS_SANITIZERS=ON| Option | Default | Description |
|---|---|---|
NUMSIM_CAS_BUILD_TESTS |
ON |
Build test suite |
NUMSIM_CAS_BUILD_EXAMPLES |
OFF |
Build examples |
NUMSIM_CAS_BUILD_BENCHMARK |
OFF |
Build benchmarks |
NUMSIM_CAS_SANITIZERS |
OFF |
Enable ASAN + UBSAN |
NUMSIM_CAS_INSTALL_LIBRARY |
auto | Install targets |
| Dependency | Version | Source | Purpose |
|---|---|---|---|
| tmech | master | FetchContent (GitHub) | Tensor numerics backend |
| GoogleTest | 1.15.2 | FetchContent (GitHub) | Testing framework |
- C++23 standard required (
cxx_std_23) - GCC 14+: Full support
- Clang 18+: Full support
- MSVC (latest): Full support (with
/bigobjfor tests)
GitHub Actions workflow runs on every push and PR:
- Ubuntu 24.04: GCC-14 (Debug/Release), Clang-18 (Debug/Release)
- macOS latest: Apple Clang (Debug/Release)
- Windows latest: MSVC (Debug/Release)
- Separate clang-format check workflow
| File | Purpose |
|---|---|
expression.h |
Abstract base class for all nodes |
expression_holder.h |
RAII wrapper (shared_ptr) |
visitor_base.h |
Virtual visitor infrastructure (3 variants) |
tag_invoke.h |
CPO foundation |
binary_ops.h |
Binary operator CPO tags (add, sub, mul, div) |
operators.h |
Global operator overloads |
n_ary_tree.h |
Hash-map based commutative container |
n_ary_vector.h |
Vector-based ordered container |
binary_op.h |
Binary node base template |
unary_op.h |
Unary node base template |
symbol_base.h |
Symbol/variable base template |
domain_traits.h |
Domain trait template + helpers |
hash_functions.h |
Hash combining primitives |
scalar_number.h |
Type-erasing numeric wrapper |
assumptions.h |
Expression assumptions and relations |
evaluator_base.h |
Evaluation infrastructure |
diff.h |
Differentiation CPO |
substitute.h |
Substitution CPO |
promote_expr.h |
Type promotion CPO |
make_constant.h |
Constant creation CPO |
make_negative.h |
Negation CPO |
contains_expression.h |
Subexpression search utilities |
limit_result.h |
Limit behavior types |
cas_error.h |
Error types |
core_fwd.h |
Forward declarations |
print_mul_fractions.h |
Fraction printing helper |
| File | Purpose |
|---|---|
simplifier_add.h |
Generic addition simplification (~510 LOC) |
simplifier_sub.h |
Generic subtraction simplification (~419 LOC) |
simplifier_mul.h |
Generic multiplication simplification (~88 LOC) |
simplifier_pow.h |
Generic power simplification (~179 LOC) |
| File | Purpose |
|---|---|
scalar_expression.h |
Scalar expression base class |
scalar_node_list.h |
Node type registry macro |
scalar_visitor_typedef.h |
Visitor type definitions |
scalar_domain_traits.h |
Scalar domain trait specialization |
scalar_operators.h |
Operator CPO implementations |
scalar_std.h |
Math functions (sin, cos, pow, etc.) |
scalar_functions.h |
Utility functions |
scalar_globals.h |
Global zero/one accessors |
scalar_make_constant.h |
Constant creation |
scalar_assume.h |
Assumption helpers |
scalar_diff.h |
Differentiation entry point |
scalar_io.h |
Stream output |
scalar.h |
Symbol node |
scalar_zero.h, scalar_one.h, scalar_constant.h |
Constant nodes |
scalar_add.h, scalar_mul.h |
N-ary arithmetic |
scalar_negative.h, scalar_power.h |
Unary/binary arithmetic |
scalar_sin.h ... scalar_sign.h |
Function nodes |
scalar_div.h, scalar_rational.h |
Division nodes |
scalar_named_expression.h |
Named function wrapper |
| File | Purpose |
|---|---|
scalar_simplifier_add.h |
Addition dispatcher + visitors |
scalar_simplifier_sub.h |
Subtraction dispatcher + visitors |
scalar_simplifier_mul.h |
Multiplication dispatcher + visitors |
scalar_simplifier_pow.h |
Power dispatcher + visitors |
| File | Purpose |
|---|---|
scalar_evaluator.h |
Numeric evaluation |
scalar_printer.h |
String output with precedence |
scalar_differentiation.h |
Symbolic differentiation |
scalar_rebuild_visitor.h |
Expression transformation base |
scalar_substitution.h |
Find-and-replace |
| File | Purpose |
|---|---|
tensor_expression.h |
Tensor expression base class |
tensor_node_list.h |
Node type registry |
tensor_visitor_typedef.h |
Visitor type definitions |
tensor_domain_traits.h |
Tensor domain traits |
tensor_operators.h |
Operator CPO implementations |
tensor_functions.h |
High-level API (sym, dev, inner_product, etc.) |
tensor_space.h |
Algebraic space classification |
sequence.h |
Index sequence manipulation |
tensor_assume.h |
Tensor assumptions |
tensor_io.h |
Stream output |
projection_tensor.h |
Projector node and factories |
projector_algebra.h |
Projector algebraic rules |
tensor.h |
Symbol node |
tensor_zero.h |
Zero tensor |
identity_tensor.h, kronecker_delta.h |
Identity/delta nodes |
tensor_add.h, tensor_mul.h, tensor_pow.h |
Arithmetic nodes |
tensor_negative.h, tensor_scalar_mul.h |
Unary/binary arithmetic |
tensor_inv.h |
Matrix inverse |
inner_product_wrapper.h, outer_product_wrapper.h |
Product nodes |
basis_change_imp.h |
Index permutation |
simple_outer_product.h |
Iterated outer product |
| File | Purpose |
|---|---|
tensor_simplifier_add.h |
Tensor addition simplification |
tensor_simplifier_mul.h |
Tensor multiplication simplification |
tensor_with_scalar_simplifier_mul.h |
Scalar-tensor multiplication |
tensor_inner_product_simplifier.h |
Inner product simplification |
tensor_projector_simplifier.h |
Projector algebra simplification |
| File | Purpose |
|---|---|
tensor_evaluator.h |
Numeric evaluation (tmech backend) |
tensor_printer.h |
String output |
tensor_differentiation.h |
Symbolic differentiation |
tensor_rebuild_visitor.h |
Expression transformation base |
tensor_substitution.h |
Find-and-replace |
| File | Purpose |
|---|---|
tensor_to_scalar_expression.h |
T2S expression base class |
tensor_to_scalar_node_list.h |
Node type registry |
tensor_to_scalar_visitor_typedef.h |
Visitor type definitions |
tensor_to_scalar_domain_traits.h |
T2S domain traits |
tensor_to_scalar_operators.h |
Operator CPO implementations |
tensor_to_scalar_std.h |
pow() and log() functions |
tensor_to_scalar_functions.h |
trace(), det(), norm(), dot() |
tensor_to_scalar_io.h |
Stream output |
tensor_trace.h, tensor_det.h, tensor_norm.h, tensor_dot.h |
Unary nodes |
tensor_to_scalar_zero.h, tensor_to_scalar_one.h |
Constants |
tensor_to_scalar_scalar_wrapper.h |
Scalar bridge |
tensor_to_scalar_add.h, tensor_to_scalar_mul.h, tensor_to_scalar_pow.h |
Arithmetic |
tensor_to_scalar_negative.h, tensor_to_scalar_log.h |
Unary operations |
tensor_inner_product_to_scalar.h |
Indexed inner product |
| File | Domain | Description |
|---|---|---|
ScalarExpressionTest.h |
Scalar | Arithmetic, printing, canonicalization |
ScalarDifferentiationTest.h |
Scalar | All derivative rules |
ScalarEvaluatorTest.h |
Scalar | Numeric evaluation |
ScalarAssumptionTest.h |
Scalar | Assumption inference |
ScalarSubstitutionTest.h |
Scalar | Expression substitution |
TensorExpressionTest.h |
Tensor | Arithmetic, products, projections |
TensorDifferentiationTest.h |
Tensor | Tensor derivatives |
TensorEvaluatorTest.h |
Tensor | Numeric evaluation with tmech |
TensorProjectorDifferentiationTest.h |
Tensor | Projector derivatives |
TensorSpacePropagationTest.h |
Tensor | Space tracking through operations |
TensorSubstitutionTest.h |
Tensor | Tensor substitution |
TensorToScalarExpressionTest.h |
T2S | T2S arithmetic |
TensorToScalarDifferentiationTest.h |
T2S | T2S derivatives |
TensorToScalarEvaluatorTest.h |
T2S | T2S evaluation |
TensorToScalarSubstitutionTest.h |
T2S | T2S substitution |
CoreBugFixTest.h |
Core | Regression tests |
LimitVisitorTest.h |
Core | Limit analysis |
cas_test_helpers.h |
All | EXPECT_PRINT, EXPECT_SAME_PRINT macros |
main.cpp |
All | Test runner entry point |
| Term | Definition |
|---|---|
| CAS | Computer Algebra System -- software for symbolic mathematics |
| CPO | Customization Point Object -- a callable that dispatches to user-provided implementations via ADL |
| CRTP | Curiously Recurring Template Pattern -- a template pattern where a class inherits from a template parameterized by itself |
| DAG | Directed Acyclic Graph -- a graph with directed edges and no cycles |
| Domain | One of the three expression types: scalar, tensor, tensor-to-scalar |
| Domain traits | A traits template providing type and function information for a domain |
| Expression holder | RAII wrapper around shared_ptr to an expression node |
| Hash combine | Function that mixes a new value into an existing hash seed |
| Inner product | Tensor contraction over shared indices (e.g., A:B = A_ij * B_ij) |
| N-ary tree | A tree node with variable number of children (used for add/mul) |
| Node | A concrete expression type (e.g., scalar_add, tensor_pow) |
| Node list macro | X-macro listing all node types for visitor generation |
| Outer product | Tensor product without contraction (A ⊗ B)_ijkl = A_ij * B_kl |
| Projection tensor | Fourth-order tensor that extracts a specific component (sym, dev, vol, skew) |
| RAII | Resource Acquisition Is Initialization -- C++ idiom tying resource lifecycle to object scope |
| Sequence | Ordered list of indices for tensor operations (1-based input, 0-based storage) |
| Tag invoke | A pattern for ADL-based customization using tag types |
| Tensor space | Classification of tensor by symmetry and trace properties |
| T2S | Tensor-to-scalar -- expressions that map tensors to scalars (trace, det, norm, dot) |
| tmech | C++ tensor mechanics library used as numerical backend |
| Visitable | A node that can accept a visitor (has accept() methods) |
| Visitor | A class with operator() overloads for each node type |