Skip to main content

Expressions and Math

See also: Context and Bindings · Story Engine · Dynamic Component System

Everything math in Stretched flows through one small, safe expression language. All of it lives in libs/stretched-components/src/dynamic-host/context/. One parser produces one AST, and every consumer — boolean validation, exact decimal evaluation, series generation, LaTeX rendering — walks that same AST, so a rendered equation and its computed value can never drift.

The safe parser — validator-expression.ts

A hand-rolled tokenizer → recursive-descent parser → tree-walking interpreter. No eval, no new Function — stored expressions from untrusted configs are safe to run.

Language surface:

literalsnumbers, '…'/"…" strings, true, false, null
comparison=== !== == != < > <= >=
logical&& || !
arithmetic+ - * / %, unary -
Math.*any Math function/constant — Math.random removed (purity)
references$value (the field's own value), $name (current story), $story.name (cross-story)
helpers$helper(...) calls the evaluating component provides (e.g. sectionGate's $complete())

Anything else — arbitrary identifiers, property access or calls outside Math — is rejected. $value.prop parses as the cross-ref story value, variable prop, not property access: there is intentionally no way to reach into a value's prototype.

Determinism/safety guarantees (details in dynamic-host/control-flow.md): expression length is capped (MAX_EXPRESSION_LENGTH = 1000) and parse depth capped (MAX_PARSE_DEPTH = 64), throwing ExpressionError instead of hanging; the evaluator is a pure function of (value, referenced variables).

API: evaluateExpression(expr, env) (env supplies value, resolveRef, helpers), parseExpression(source)ExpressionNode, checkExpressionSyntax(expr) → error string or null (the builders' live syntax line).

Consumers: variable validators (expression = the VALID condition — see Context and Bindings) and sectionGate showWhen/unlockWhen (see Story Engine).

Exact decimal math — decimal-config.ts + decimal-expression.ts

Money math never runs in binary floats:

  • decimal-config.ts exports Dec, a cloned decimal.js constructor (precision 34 ≈ IEEE decimal128, ROUND_HALF_UP) — cloned so our config can never leak into, or be changed by, any other decimal.js use.
  • decimal-expression.ts is a decimal-native evaluator over the same AST: every value is a Dec, so arithmetic is exact. Deliberately numeric-only — strings, booleans, and comparisons are rejected. Scope variables are readable bare (rate) or as $rate; a curated Math.* subset maps to Decimal methods (Math.powx.pow(y), Math.log → natural log, …); constants Math.PI/Math.E are computed at full precision. evaluateDecimal(source | ast, scope)Decimal.

Financial helpers — financial.ts

Time-value-of-money functions computed in Dec, callable from decimal expressions bare or as $pmt(…):

HelperMeaning
pmt(rate, nper, pv)level payment amortizing pv (straight-line when rate = 0)
emialias of pmt
fv(rate, nper, pmt)future value of end-of-period payments
pv(rate, nper, pmt)present value of end-of-period payments
npv(rate, ...cashflows)Excel convention — first cashflow one period out
rule72(rate)periods to double (0.08 = 8%)

All rates are periodic (annual 6% monthly → 0.06 / 12); payments end-of-period; results positive for positive inputs (no accounting sign convention) so they drop straight into charts and KPIs.

Data generators — { $gen } (data-generator.ts)

A generator behaves like a map: given inputs and a count, it loops i = 0 … count-1 and evaluates decimal expressions per item, producing the shape charts consume (number[] via value, or { x, y }-style objects via item):

{
"$gen": {
"count": 6,
"let": { "base": { "$ref": "budget.income" }, "rate": 0.03 },
"item": { "x": "i", "y": "base * Math.pow(1 + rate, i)" },
},
}
  • Expressions see $i / i plus every let input, Math.*, and the financial helpers. All math is exact Decimal; outputs convert to number (optionally rounded via round) at the boundary.
  • count is validated and capped at 100,000 (GeneratorError otherwise); non-finite results throw.
  • let inputs may be { $ref } bindings, so a generated series reacts to story variables — and the stored JSON stays tiny (a spec, not the expanded array). Resolution happens in resolve-refs.ts; a bad generator degrades to undefined, fail-soft like an unresolved binding. See Context and Bindings.

Equations — expression-latex.ts + the Equation component

  • expressionToLatex(source | ast) renders the whole language (arithmetic, comparisons, logic, Math.*, financial helpers) as a LaTeX string by walking the same AST — / becomes \frac, Math.pow an exponent, && \land, etc. Pure string output.

  • Equation (components/equation/equation.ts, <sc-equation>, dynamic key equation, kind: 'props') displays a formula in three modes that can never disagree because all three come from the one AST:

    • rendered — typeset with KaTeX using output: 'mathml', so the browser typesets native MathML and no KaTeX CSS or fonts ship;
    • source — the raw formula text;
    • result — the exact Decimal value evaluated against the scope prop (rounded to round places).

    Props include initialMode, isShowingModes (mode toggle chips), and isDisplayMode (block vs. inline math). The Page Builder uses an inline Equation as the live preview under every validator expression.