Design rationale
Why type-based dispatch?
StencilCore's scalar CAS encodes expression structure in the type system rather than in runtime tags. Every leaf carries its role as a type parameter (Null{Bool}, Unity{SMatrix{2,2,Bool,4}}, Constant{Float64}, …) and every interior node's function and argument types are fully reified (Scalar{typeof(+), Tuple{Var{:τ,Float64}, Constant{Float64}}, Float64}). This section explains the concrete benefits that design unlocks.
1. materialize is trivially implemented
materialize reduces a scalar tree to a single value. Because each leaf's role is baked into its type, reduction is pure dispatch — no runtime tree-walk, no substitution dictionary, no pattern-matching on tags:
materialize(::Null{Bool}) = false
materialize(::Unity{Bool}) = true
materialize(c::Constant) = c.valInterior nodes follow the same structural recursion without overhead. Compare this to a runtime-tagged representation, where every leaf lookup requires a runtime conditional.
2. Conversion to Julia's AST is trivial
Scalar{F, A, T} already stores fn::F and the typed children args::A. The mapping to Expr is a structural recursion with no loss of information — the AST is essentially already embedded in the type:
to_expr(s::Scalar) = Expr(:call, s.fn, to_expr.(s.args)...)
to_expr(v::Var{S}) = S # the Symbol
to_expr(c::Constant) = c.val # the literal3. differentiate returns a fully inferred type
The result type of differentiate(s, v) is the Jacobian type _jacobian_type(eltype(s), eltype(v)), computed at compile time from the leaf types alone:
Number×Number→promote_type(T1, T2)SVector{N,F1}×SVector{N,F2}→SMatrix{N,N,promote_type(F1,F2)}
@code_warntype differentiate(expr, v) shows a concrete Body::T, never Any. The chain rule combinators (_diff_scalar) propagate this type through product and sum rules without widening.
4. simplify is type-stable
The @generated structural simplifier resolves every rule — Null/Unity identities, double-negation, constant fold, coefficient fold — entirely at compile time from type parameters. The return type of simplify(expr) is fully inferred:
@code_warntype simplify(Null{Bool}() + Var{:u,Float64}())
# Body::Var{:u, Float64} ← concrete, not AnyNo runtime branching on .val; no Metatheory EGraph needed for this path. Metatheory saturation (saturate_scalar) is available as an optional extension for heavier equational reasoning.
5. @generated specialization on tree shape
Since the entire tree shape (operator, arity, argument types) is reified in type parameters, @generated functions can emit code that is specific to each unique tree shape, with zero symbolic overhead:
@generated function _simplify_args(s::Scalar{F, A, T}) where {F, A, T}
N = length(A.parameters)
Expr(:tuple, [:(simplify(s.args[$i])) for i in 1:N]...)
endThe compiler sees a concrete, arity-specialized tuple expression — no runtime dispatch on arity.
6. Zero-overhead narrowing
as_linear and as_star check whether a Stencil's offset pattern matches a specific layout at compile time, from type parameters alone. A mismatch raises an ArgumentError in the constructor; a match builds the specialized stencil with a verbatim coefficient copy — no runtime predicate loop.
7. Coefficient eltype invariants are structural
Null{T} and Unity{T} always carry a Bool-shaped T (T === Bool for scalars; eltype(T) === Bool for StaticArrays). This invariant is enforced exactly once, at construction, by _to_bool_shape. No downstream code ever needs to re-check it; the type itself is the proof.
8. Materialized values are plain Julia arrays
Coefficients materialize to plain Numbers, SVectors, or SMatrixs — not wrapped in a symbolic container. They can be handed directly to BLAS/LAPACK, vectorized loops, or StaticArrays kernels without any unwrapping step.
Comparison with Symbolics.jl
Symbolics.jl is a general-purpose CAS for Julia. The two libraries make opposite trade-offs.
| Property | StencilCore | Symbolics |
|---|---|---|
| Expression type | Concrete per tree shape | Always Num |
materialize / substitute | Type dispatch, near-zero alloc | Dict-based runtime tree-walk |
simplify return type | Fully inferred (@code_warntype clean) | Num (opaque to the compiler) |
differentiate return type | Fully inferred (Jacobian type) | Num |
| Assembly from expression | Direct dispatch → CSC kernel | Requires build_function / code generation |
| Structural invariants | Enforced by the type system | Runtime checks |
| Generality | Fixed operator + shape vocabulary | Arbitrary symbolic math |
| Metatheory / EGraph | Optional weakdep extension | Not used |
| Per-use-site cost | Zero (compiler-specialized) | Dynamic dispatch through Num |
The core trade-off
StencilCore pays with type explosion: each unique expression shape (operator + argument types) spawns a new compiler specialization. For stencil coefficients — small, fixed-arity, shape-homogeneous trees — this is acceptable; the set of distinct shapes encountered at runtime is bounded.
Symbolics pays with type erasure: every expression has type Num regardless of its structure, so the compiler can never see through it. This is the right choice for a general-purpose CAS that must handle user-supplied expressions of unbounded shape and depth; it is the wrong choice when the expression vocabulary is small and the result types must be inferred.
If you need general symbolic manipulation — trigonometric identities, polynomial factorisation, arbitrary user-defined rewrite rules — use Symbolics. StencilCore's scalar CAS is narrow by design: it covers exactly the coefficient algebra needed to differentiate and simplify stencil expressions on structured meshes.