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:
| literals | numbers, '…'/"…" 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.tsexportsDec, 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.tsis a decimal-native evaluator over the same AST: every value is aDec, so arithmetic is exact. Deliberately numeric-only — strings, booleans, and comparisons are rejected. Scope variables are readable bare (rate) or as$rate; a curatedMath.*subset maps to Decimal methods (Math.pow→x.pow(y),Math.log→ natural log, …); constantsMath.PI/Math.Eare 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(…):
| Helper | Meaning |
|---|---|
pmt(rate, nper, pv) | level payment amortizing pv (straight-line when rate = 0) |
emi | alias 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/iplus everyletinput,Math.*, and the financial helpers. All math is exactDecimal; outputs convert tonumber(optionally rounded viaround) at the boundary. countis validated and capped at 100,000 (GeneratorErrorotherwise); non-finite results throw.letinputs 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 inresolve-refs.ts; a bad generator degrades toundefined, 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.powan exponent,&&\land, etc. Pure string output. -
Equation(components/equation/equation.ts,<sc-equation>, dynamic keyequation,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
Decimalvalue evaluated against thescopeprop (rounded toroundplaces).
Props include
initialMode,isShowingModes(mode toggle chips), andisDisplayMode(block vs. inline math). The Page Builder uses an inlineEquationas the live preview under every validator expression. - rendered — typeset with KaTeX using