Skip to content

Latest commit

 

History

History
159 lines (122 loc) · 7.25 KB

SIMPLIFY.md

File metadata and controls

159 lines (122 loc) · 7.25 KB

Simplification Invariants

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 is not simplification

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.

simplify() and the Simplify operator

The .simplify() method and the Simplify operator deliberately have different contracts.

The method is rule-based and value-blind

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();

The operator evaluates before applying rules

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.

Assigned values do not influence the method

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.

Generic-real simplification policy

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.

Combining or splitting logarithms requires non-negative arguments

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.

When a real-only rewrite must decline

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 complex or imaginary is 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.

Recursion and ordering constraints

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