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.val

Interior 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 literal

3. 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 × Numberpromote_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 Any

No 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]...)
end

The 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.

PropertyStencilCoreSymbolics
Expression typeConcrete per tree shapeAlways Num
materialize / substituteType dispatch, near-zero allocDict-based runtime tree-walk
simplify return typeFully inferred (@code_warntype clean)Num (opaque to the compiler)
differentiate return typeFully inferred (Jacobian type)Num
Assembly from expressionDirect dispatch → CSC kernelRequires build_function / code generation
Structural invariantsEnforced by the type systemRuntime checks
GeneralityFixed operator + shape vocabularyArbitrary symbolic math
Metatheory / EGraphOptional weakdep extensionNot used
Per-use-site costZero (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.

When to use Symbolics instead

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.