This document records internal simplification contracts that are easy to break
when changing canonicalization, evaluation, or rewrite rules. User-facing
examples and API guidance belong in
doc/11-guide-simplify.md; unfinished feature
work belongs in ROADMAP.md.
Canonicalization runs when expressions are boxed or parsed. It normalizes
structure and folds exact numeric operands before any .simplify() call.
Examples include:
Add(2, x, 5)→Add(x, 7)Add(1/3, x, 2/3)→Add(x, 1)Multiply(2, x, 5)→Multiply(10, x)Multiply(1/2, x, 2)→x
The generic-symbol folds x/x → 1, 1^x → 1, x/0, 0/x, and x/∞ also
belong to canonicalization. Their conventions are documented in
ARCHITECTURE.md.
The .simplify() method and the Simplify operator deliberately have
different contracts.
expr.simplify() applies simplification rules and folds constant
subexpressions. It does not substitute assigned symbol values.
Constant folding is part of the method, not of a rule, so it does not depend
on the rule list: the default rules, a caller's own list and rules: null fold
the same subexpressions (decision of 2026-10-12, issue #306). A subexpression
is folded when it is pure, has no unknowns, mentions no variable with an
assigned value, and its evaluation is a number literal (or a residue class). A
symbol bound by an enclosing Integrate or function literal is not an
unknown. So 3!, ln(e), sin(π), ∫_0^1 t² dt and the determinant of a
numeric matrix fold, at the top level as well as inside a sum. ln 2, √2
and 2^(3/5) stay, because evaluate() keeps them symbolic (the exactness
contract). Random() + 1 stays, because Random is not pure. w² with
w := 5 stays, because the method is value-blind. An iterated Sum or
Product, and a node that holds one, a Hold or an Annotated wrapper, are
never folded: folding does not enumerate terms, and the value of a sum comes
from its closed-form rules. An evaluation that throws declines the fold.
Consequently, a head whose result comes only from an evaluation handler — such
as Determinant, Trace, Length, D, or Integrate — remains unchanged
under the method when it has an unknown and no simplification rule covers it.
For an evaluate-then-simplify operation, use:
expr.evaluate().simplify();Simplify(expr) evaluates its argument and then simplifies the result. It runs
operator handlers and observes assigned values. It is the operator-surface
counterpart of expr.evaluate().simplify().
N() also runs handlers, but requests a numeric approximation rather than an
exact result.
During .simplify(), assigned non-constant symbols are treated as valueless
while retaining their declared types. Sign and parity information therefore
comes from types and assumptions, not from the current assigned value.
For example, with w := 5, abs(w).simplify() remains abs(w) and
sqrt(w^2).simplify() is abs(w). The result remains valid if w is later
assigned -3. An assumption such as assume(w > 0) may license the stronger
rewrite because an assumption is part of the symbolic context.
Constants such as π retain their values because their value is part of their identity.
On the operator surface, HoldValues(body) provides the value-blind route. It
shields all assigned free symbols, or a selected subset, for the duration of
evaluation while preserving declared types and assumptions.
This is the authoritative policy for real-only rewrites over an unknown symbol.
An unconstrained numeric symbol is treated as a generic real unless declared otherwise. Identities that are valid over the reals may therefore apply even when they differ on the complex plane or at a measure-zero exceptional point.
For an unconstrained x:
| Simplification | Result | Rule class |
|---|---|---|
ln(x) + ln(y) |
unchanged | Requires non-negative arguments |
ln(x^3) |
3 ln(x) |
Generic-real; differs at negative reals |
ln(x^2) |
2 ln(abs(x)) |
Always sound over the reals |
sqrt(x^2) |
abs(x) |
Always sound over the reals |
Even powers use the absolute-value form. Odd and irrational exponents use the optimistic generic-real convention.
ln(a) + ln(b) → ln(ab) and ln(a) − ln(b) → ln(a/b) (and the same for
log_c with one base), and the reverse ln(a/b) → ln(a) − ln(b), apply only
when every argument is provably non-negative: by its value (ln(2) + ln(3) is
ln(6)), by its type, by an assumption (assume(x > 0)), or because it is an
absolute value (ln|x + 1| − ln|x + 2| is ln(|x + 1|/|x + 2|)). For an
unconstrained symbol the expression stays as it is: ln(x) + ln(y),
ln(x/y) and ln(1/x) are unchanged.
For real non-negative arguments the principal values of both sides are
equal, also at 0, where both are -∞. For negative arguments both sides are
defined and different: at a = b = -1, ln(a) + ln(b) is 2πi and ln(ab)
is 0. When this policy was first written, assumptions were not reliable
enough to ask a user to state x > 0, so these rewrites applied to every
unconstrained symbol. They are now (decision of 2026-10-01,
#397).
The solver still combines the logarithms of an equation for any argument
(combineLogarithmsOfUnknown() in boxed-expression/solve.ts): it checks
each root against the original equation, which rejects a root that the
combination adds.
A real-only rewrite is skipped when the operand's declared type admits
genuinely non-real values: its type matches complex or imaginary, but not
real. The shared gate is isEligibleRealRewrite in
src/compute-engine/function-properties/index.ts.
- An unconstrained symbol is eligible.
- A symbol declared
complexorimaginaryis not eligible. - A symbol known positive through assumptions may use a stronger form without an absolute value.
- Declared real subtypes, such as
integer, use the real-safe absolute-value form unless assumptions prove a sign.
Branch-cut-sensitive logarithm combinations additionally consult the branch-cut guard and remain symbolic when an operand is provably on the negative-real cut.
- Do not call
.simplify()from inside a simplification rule or from a helper invoked by a rule. Re-entering the rule engine can recurse indefinitely. - Evaluation is not a subset of simplification, and simplification is not a subset of evaluation. Choose the operation that matches the intended contract.
- A transformation that needs assigned values belongs on an evaluation path, not in the value-blind method.
- Domain-sensitive rewrites must be gated by declared types and assumptions; they must not inspect an assigned value as evidence.
- Completed regression campaigns belong in tests and Git history rather than in this specification.