feat(sim-builder): фаза 0 — рантайм SimEngine + безопасный движок выражений + адаптер LabRegistry
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
'use strict';
|
||||
/* ════════════════════════════════════════════════════════════════════════
|
||||
SimExpr — безопасный движок выражений для конструктора симуляций (Фаза 0).
|
||||
|
||||
Спека симуляции — это ДАННЫЕ, которые шарятся между людьми, поэтому код
|
||||
выражений НИКОГДА не исполняется через eval/new Function. Здесь — собственный
|
||||
конвейер: токенайзер → AST → evaluate(ast, env). Логика расширяет парсер
|
||||
y=f(x) из graph.js (тот же подход к токенам и неявному умножению), но:
|
||||
- окружение многопеременное: любой идентификатор берётся из env (params, t,
|
||||
значения объектов), а не только x;
|
||||
- добавлены сравнения (< <= > >= == !=), логика (&& ||), тернарник ?:,
|
||||
функции min/max/mod/log(base,x) и константы pi/e;
|
||||
- результат компиляции — AST + замыкание fn(env), считается детерминированно.
|
||||
|
||||
Ошибки времени выполнения (деление на 0, NaN, неизвестный идентификатор) НЕ
|
||||
кидаются из fn(env): они дают 0 (или флаг через evalSafe), чтобы один кривой
|
||||
кадр не ронял весь рантайм. Ошибки КОМПИЛЯЦИИ (синтаксис) возвращаются строкой
|
||||
в compile(src).error и не бросаются.
|
||||
|
||||
API:
|
||||
SimExpr.compile(src) -> { ast, fn, error }
|
||||
fn(env) -> number (никогда не бросает; при сбое -> 0)
|
||||
SimExpr.evaluate(ast, env) -> number (никогда не бросает; при сбое -> 0)
|
||||
SimExpr.evalSafe(ast, env) -> { value, error } (для отладки/билдера)
|
||||
SimExpr.FUNCTIONS -> Set имён whitelisted-функций (для подсветки/билдера)
|
||||
SimExpr.CONSTANTS -> Set имён констант (pi, e)
|
||||
|
||||
env — простой объект { имя: number }. Имена объектов спеки удобно передавать
|
||||
как "obj.x"/"obj.y" — для этого допускается точка в идентификаторе.
|
||||
════════════════════════════════════════════════════════════════════════ */
|
||||
(function (global) {
|
||||
|
||||
/* ── whitelist функций (имя -> арность: 1, 2 или -1 для переменной) ── */
|
||||
// -1 => принимает >=1 аргумент (min/max). log: 1 арг = ln по основанию e? нет —
|
||||
// log(x) трактуем как десятичный (как в graph.js log===log10), log(b,x)=log_b(x).
|
||||
var FN_ARITY = {
|
||||
sin: 1, cos: 1, tan: 1, tg: 1, ctg: 1, cot: 1,
|
||||
asin: 1, acos: 1, atan: 1, arcsin: 1, arccos: 1, arctan: 1, arctg: 1,
|
||||
sqrt: 1, abs: 1, exp: 1, ln: 1, log: -2, log2: 1, log10: 1,
|
||||
floor: 1, ceil: 1, round: 1, sign: 1,
|
||||
min: -1, max: -1, mod: 2, atan2: 2, pow: 2, hypot: -1
|
||||
};
|
||||
|
||||
// Реализации. Все защищены от исключений на уровне evaluate (домены проверяются
|
||||
// там, где можно дать NaN -> наружу станет 0).
|
||||
var FN_IMPL = {
|
||||
sin: Math.sin, cos: Math.cos, tan: Math.tan, tg: Math.tan,
|
||||
ctg: function (x) { return 1 / Math.tan(x); },
|
||||
cot: function (x) { return 1 / Math.tan(x); },
|
||||
asin: Math.asin, acos: Math.acos, atan: Math.atan,
|
||||
arcsin: Math.asin, arccos: Math.acos, arctan: Math.atan, arctg: Math.atan,
|
||||
sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp,
|
||||
ln: Math.log,
|
||||
log: function (a, b) {
|
||||
// log(x) -> log10(x); log(base, x) -> log_base(x)
|
||||
if (b === undefined) return Math.log(a) / Math.LN10;
|
||||
return Math.log(b) / Math.log(a);
|
||||
},
|
||||
log2: Math.log2, log10: Math.log10,
|
||||
floor: Math.floor, ceil: Math.ceil, round: Math.round, sign: Math.sign,
|
||||
min: Math.min, max: Math.max,
|
||||
mod: function (a, b) { return b === 0 ? 0 : a % b; },
|
||||
atan2: Math.atan2, pow: Math.pow, hypot: Math.hypot
|
||||
};
|
||||
|
||||
var CONSTANTS = { pi: Math.PI, PI: Math.PI, e: Math.E, E: Math.E, tau: Math.PI * 2 };
|
||||
|
||||
/* ════════════════════ TOKENIZER ════════════════════ */
|
||||
|
||||
function tokenize(src) {
|
||||
var out = [];
|
||||
var i = 0, n = src.length;
|
||||
while (i < n) {
|
||||
var ch = src[i];
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { i++; continue; }
|
||||
|
||||
/* число */
|
||||
if ((ch >= '0' && ch <= '9') || (ch === '.' && src[i + 1] >= '0' && src[i + 1] <= '9')) {
|
||||
var j = i;
|
||||
while (j < n && src[j] >= '0' && src[j] <= '9') j++;
|
||||
if (j < n && src[j] === '.') { j++; while (j < n && src[j] >= '0' && src[j] <= '9') j++; }
|
||||
if (j < n && (src[j] === 'e' || src[j] === 'E')) {
|
||||
var k = j + 1;
|
||||
if (k < n && (src[k] === '+' || src[k] === '-')) k++;
|
||||
if (k < n && src[k] >= '0' && src[k] <= '9') {
|
||||
j = k; while (j < n && src[j] >= '0' && src[j] <= '9') j++;
|
||||
}
|
||||
}
|
||||
out.push({ t: 'num', v: parseFloat(src.slice(i, j)) });
|
||||
i = j; continue;
|
||||
}
|
||||
|
||||
/* идентификатор (буквы/цифры/_/.) — точка допускает obj.x */
|
||||
if (isIdentStart(ch)) {
|
||||
var p = i + 1;
|
||||
while (p < n && isIdentPart(src[p])) p++;
|
||||
out.push({ t: 'id', v: src.slice(i, p) });
|
||||
i = p; continue;
|
||||
}
|
||||
|
||||
/* двухсимвольные операторы */
|
||||
var two = src.substr(i, 2);
|
||||
if (two === '<=' || two === '>=' || two === '==' || two === '!=' ||
|
||||
two === '&&' || two === '||') {
|
||||
out.push({ t: 'op', v: two }); i += 2; continue;
|
||||
}
|
||||
|
||||
/* односимвольные операторы / скобки / запятая / ?: */
|
||||
if ('+-*/^%()<>?:,'.indexOf(ch) !== -1) {
|
||||
out.push({ t: 'op', v: ch }); i++; continue;
|
||||
}
|
||||
// одиночный '=' трактуем как сравнение (часто пишут a=b по привычке)
|
||||
if (ch === '=') { out.push({ t: 'op', v: '==' }); i++; continue; }
|
||||
// одиночный '!' — логическое НЕ
|
||||
if (ch === '!') { out.push({ t: 'op', v: '!' }); i++; continue; }
|
||||
|
||||
throw new Error('Неизвестный символ: «' + ch + '»');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isIdentStart(ch) {
|
||||
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch === '_';
|
||||
}
|
||||
function isIdentPart(ch) {
|
||||
return isIdentStart(ch) || (ch >= '0' && ch <= '9') || ch === '.';
|
||||
}
|
||||
|
||||
/* Вставка неявного умножения: 2x -> 2*x, 2(.. -> 2*(.., )( -> )*(, )x -> )*x.
|
||||
Функция перед '(' умножение НЕ получает. */
|
||||
function insertImplicitMul(tokens) {
|
||||
var out = [];
|
||||
for (var i = 0; i < tokens.length; i++) {
|
||||
out.push(tokens[i]);
|
||||
var cur = tokens[i], nxt = tokens[i + 1];
|
||||
if (!nxt) continue;
|
||||
var curIsFn = cur.t === 'id' && Object.prototype.hasOwnProperty.call(FN_ARITY, cur.v);
|
||||
var curEnds = cur.t === 'num' ||
|
||||
(cur.t === 'id' && !curIsFn) ||
|
||||
(cur.t === 'op' && cur.v === ')');
|
||||
var nxtStarts = nxt.t === 'num' ||
|
||||
nxt.t === 'id' ||
|
||||
(nxt.t === 'op' && nxt.v === '(');
|
||||
// не вставляем '*' если cur — функция (тогда дальше идёт её '(')
|
||||
if (curEnds && nxtStarts) out.push({ t: 'op', v: '*' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ════════════════════ PARSER (рекурсивный спуск -> AST) ════════════════════
|
||||
|
||||
Грамматика (по убыванию приоритета связывания):
|
||||
ternary := logicOr ('?' ternary ':' ternary)?
|
||||
logicOr := logicAnd ('||' logicAnd)*
|
||||
logicAnd := compare ('&&' compare)*
|
||||
compare := addSub (('<'|'<='|'>'|'>='|'=='|'!=') addSub)?
|
||||
addSub := mulDiv (('+'|'-') mulDiv)*
|
||||
mulDiv := power (('*'|'/'|'%') power)*
|
||||
power := unary ('^' power)? // правоассоциативно
|
||||
unary := ('-'|'+'|'!') unary | primary
|
||||
primary := num | ident | const | fn(args...) | '(' ternary ')'
|
||||
|
||||
Узлы AST:
|
||||
{ k:'num', v }
|
||||
{ k:'var', name }
|
||||
{ k:'const', v }
|
||||
{ k:'bin', op, a, b }
|
||||
{ k:'un', op, a }
|
||||
{ k:'cmp', op, a, b }
|
||||
{ k:'logic', op, a, b }
|
||||
{ k:'not', a }
|
||||
{ k:'cond', c, a, b }
|
||||
{ k:'call', name, args:[...] }
|
||||
*/
|
||||
|
||||
function parse(tokens) {
|
||||
var pos = 0;
|
||||
function peek() { return tokens[pos]; }
|
||||
function next() { return tokens[pos++]; }
|
||||
function expect(v) {
|
||||
var t = peek();
|
||||
if (!t || t.v !== v) throw new Error('Ожидалось «' + v + '»');
|
||||
pos++;
|
||||
}
|
||||
function isOp(v) { var t = peek(); return t && t.t === 'op' && t.v === v; }
|
||||
|
||||
function ternary() {
|
||||
var c = logicOr();
|
||||
if (isOp('?')) {
|
||||
next();
|
||||
var a = ternary();
|
||||
expect(':');
|
||||
var b = ternary();
|
||||
return { k: 'cond', c: c, a: a, b: b };
|
||||
}
|
||||
return c;
|
||||
}
|
||||
function logicOr() {
|
||||
var l = logicAnd();
|
||||
while (isOp('||')) { next(); l = { k: 'logic', op: '||', a: l, b: logicAnd() }; }
|
||||
return l;
|
||||
}
|
||||
function logicAnd() {
|
||||
var l = compare();
|
||||
while (isOp('&&')) { next(); l = { k: 'logic', op: '&&', a: l, b: compare() }; }
|
||||
return l;
|
||||
}
|
||||
function compare() {
|
||||
var l = addSub();
|
||||
var t = peek();
|
||||
if (t && t.t === 'op' && (t.v === '<' || t.v === '<=' || t.v === '>' ||
|
||||
t.v === '>=' || t.v === '==' || t.v === '!=')) {
|
||||
var op = next().v;
|
||||
return { k: 'cmp', op: op, a: l, b: addSub() };
|
||||
}
|
||||
return l;
|
||||
}
|
||||
function addSub() {
|
||||
var l = mulDiv();
|
||||
while (isOp('+') || isOp('-')) { var op = next().v; l = { k: 'bin', op: op, a: l, b: mulDiv() }; }
|
||||
return l;
|
||||
}
|
||||
function mulDiv() {
|
||||
var l = power();
|
||||
while (isOp('*') || isOp('/') || isOp('%')) { var op = next().v; l = { k: 'bin', op: op, a: l, b: power() }; }
|
||||
return l;
|
||||
}
|
||||
function power() {
|
||||
var base = unary();
|
||||
if (isOp('^')) { next(); return { k: 'bin', op: '^', a: base, b: power() }; }
|
||||
return base;
|
||||
}
|
||||
function unary() {
|
||||
if (isOp('-')) { next(); return { k: 'un', op: '-', a: unary() }; }
|
||||
if (isOp('+')) { next(); return unary(); }
|
||||
if (isOp('!')) { next(); return { k: 'not', a: unary() }; }
|
||||
return primary();
|
||||
}
|
||||
function primary() {
|
||||
var t = peek();
|
||||
if (!t) throw new Error('Неожиданный конец выражения');
|
||||
|
||||
if (t.t === 'num') { next(); return { k: 'num', v: t.v }; }
|
||||
|
||||
if (t.t === 'id') {
|
||||
next();
|
||||
var name = t.v;
|
||||
if (Object.prototype.hasOwnProperty.call(FN_ARITY, name)) {
|
||||
expect('(');
|
||||
var args = [];
|
||||
if (!isOp(')')) {
|
||||
args.push(ternary());
|
||||
while (isOp(',')) { next(); args.push(ternary()); }
|
||||
}
|
||||
expect(')');
|
||||
checkArity(name, args.length);
|
||||
return { k: 'call', name: name, args: args };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(CONSTANTS, name)) {
|
||||
return { k: 'const', v: CONSTANTS[name] };
|
||||
}
|
||||
// переменная окружения
|
||||
return { k: 'var', name: name };
|
||||
}
|
||||
|
||||
if (t.t === 'op' && t.v === '(') {
|
||||
next();
|
||||
var e = ternary();
|
||||
expect(')');
|
||||
return e;
|
||||
}
|
||||
|
||||
throw new Error('Неожиданный токен: «' + t.v + '»');
|
||||
}
|
||||
|
||||
var ast = ternary();
|
||||
if (pos !== tokens.length) throw new Error('Лишние токены после выражения');
|
||||
return ast;
|
||||
}
|
||||
|
||||
function checkArity(name, got) {
|
||||
var ar = FN_ARITY[name];
|
||||
if (ar === -1) { // >=1
|
||||
if (got < 1) throw new Error('Функции «' + name + '» нужен хотя бы 1 аргумент');
|
||||
return;
|
||||
}
|
||||
if (ar === -2) { // 1..2 (log)
|
||||
if (got < 1 || got > 2) throw new Error('Функция «' + name + '» принимает 1 или 2 аргумента');
|
||||
return;
|
||||
}
|
||||
if (got !== ar) throw new Error('Функция «' + name + '» принимает ' + ar + ' арг., дано ' + got);
|
||||
}
|
||||
|
||||
/* ════════════════════ EVALUATE ════════════════════
|
||||
Чистый интерпретатор AST по окружению env. НЕ бросает наружу (см. evaluate).
|
||||
Внутренний _ev может вернуть NaN/Infinity — нормализуется в evaluate. */
|
||||
|
||||
function _ev(node, env) {
|
||||
switch (node.k) {
|
||||
case 'num': return node.v;
|
||||
case 'const': return node.v;
|
||||
case 'var': {
|
||||
var val = env ? env[node.name] : undefined;
|
||||
return typeof val === 'number' ? val : 0; // неизвестная переменная -> 0
|
||||
}
|
||||
case 'un': // только '-'
|
||||
return -_ev(node.a, env);
|
||||
case 'not':
|
||||
return _ev(node.a, env) ? 0 : 1;
|
||||
case 'bin': {
|
||||
var a = _ev(node.a, env), b = _ev(node.b, env);
|
||||
switch (node.op) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
case '*': return a * b;
|
||||
case '/': return b === 0 ? 0 : a / b; // деление на 0 -> 0
|
||||
case '%': return b === 0 ? 0 : a % b;
|
||||
case '^': return Math.pow(a, b);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case 'cmp': {
|
||||
var x = _ev(node.a, env), y = _ev(node.b, env);
|
||||
switch (node.op) {
|
||||
case '<': return x < y ? 1 : 0;
|
||||
case '<=': return x <= y ? 1 : 0;
|
||||
case '>': return x > y ? 1 : 0;
|
||||
case '>=': return x >= y ? 1 : 0;
|
||||
case '==': return x === y ? 1 : 0;
|
||||
case '!=': return x !== y ? 1 : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
case 'logic': {
|
||||
if (node.op === '&&') return (_ev(node.a, env) && _ev(node.b, env)) ? 1 : 0;
|
||||
return (_ev(node.a, env) || _ev(node.b, env)) ? 1 : 0;
|
||||
}
|
||||
case 'cond':
|
||||
return _ev(node.c, env) ? _ev(node.a, env) : _ev(node.b, env);
|
||||
case 'call': {
|
||||
var fn = FN_IMPL[node.name];
|
||||
var args = node.args;
|
||||
if (args.length === 1) return fn(_ev(args[0], env));
|
||||
if (args.length === 2) return fn(_ev(args[0], env), _ev(args[1], env));
|
||||
// переменное число (min/max/hypot)
|
||||
var vals = new Array(args.length);
|
||||
for (var i = 0; i < args.length; i++) vals[i] = _ev(args[i], env);
|
||||
return fn.apply(null, vals);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Никогда не бросает; NaN/Infinity -> 0.
|
||||
function evaluate(ast, env) {
|
||||
if (!ast) return 0;
|
||||
var v;
|
||||
try { v = _ev(ast, env); } catch (e) { return 0; }
|
||||
return (typeof v === 'number' && isFinite(v)) ? v : 0;
|
||||
}
|
||||
|
||||
// Для отладки/билдера: возвращает значение + флаг (NaN/Infinity -> error).
|
||||
function evalSafe(ast, env) {
|
||||
if (!ast) return { value: 0, error: 'нет выражения' };
|
||||
var v;
|
||||
try { v = _ev(ast, env); } catch (e) { return { value: 0, error: String(e.message || e) }; }
|
||||
if (typeof v !== 'number' || !isFinite(v)) return { value: 0, error: 'не число (NaN/∞)' };
|
||||
return { value: v, error: null };
|
||||
}
|
||||
|
||||
/* ════════════════════ COMPILE ════════════════════
|
||||
Возвращает { ast, fn, error }. Синтаксическая ошибка -> error:строка, fn:()=>0. */
|
||||
|
||||
function compile(src) {
|
||||
if (src == null) return { ast: null, fn: function () { return 0; }, error: 'пустое выражение' };
|
||||
var raw = String(src).trim();
|
||||
// допускаем ведущее "y=" / "name=" (привычка), срезаем как в graph.js
|
||||
raw = raw.replace(/^\s*[a-zA-Z_][a-zA-Z_0-9.]*\s*=(?![=])\s*/, '');
|
||||
if (!raw) return { ast: null, fn: function () { return 0; }, error: 'пустое выражение' };
|
||||
var ast;
|
||||
try {
|
||||
var toks = insertImplicitMul(tokenize(raw));
|
||||
ast = parse(toks);
|
||||
} catch (e) {
|
||||
return { ast: null, fn: function () { return 0; }, error: String(e.message || e) };
|
||||
}
|
||||
var fn = function (env) { return evaluate(ast, env); };
|
||||
return { ast: ast, fn: fn, error: null };
|
||||
}
|
||||
|
||||
/* Хелпер: значение — число вернуть как есть; строка — скомпилировать и вернуть
|
||||
{ fn(env), error, constant }. Используется движком для свойств-привязок. */
|
||||
function compileValue(value) {
|
||||
if (typeof value === 'number') {
|
||||
var c = value;
|
||||
return { fn: function () { return c; }, error: null, constant: true, ast: { k: 'num', v: c } };
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
var r = compile(value);
|
||||
return { fn: r.fn, error: r.error, constant: false, ast: r.ast };
|
||||
}
|
||||
// прочие типы -> 0
|
||||
return { fn: function () { return 0; }, error: null, constant: true, ast: { k: 'num', v: 0 } };
|
||||
}
|
||||
|
||||
var FUNCTIONS = {}; // имя -> true (как «множество» без ES6 Set ради совместимости)
|
||||
Object.keys(FN_ARITY).forEach(function (k) { FUNCTIONS[k] = true; });
|
||||
var CONSTSET = {};
|
||||
Object.keys(CONSTANTS).forEach(function (k) { CONSTSET[k] = true; });
|
||||
|
||||
global.SimExpr = {
|
||||
compile: compile,
|
||||
compileValue: compileValue,
|
||||
evaluate: evaluate,
|
||||
evalSafe: evalSafe,
|
||||
tokenize: tokenize, // экспортируем для тестов/билдера
|
||||
parse: function (src) { // удобный helper: строка -> AST (бросает при ошибке)
|
||||
return parse(insertImplicitMul(tokenize(String(src).trim())));
|
||||
},
|
||||
FUNCTIONS: FUNCTIONS,
|
||||
CONSTANTS: CONSTSET
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Reference in New Issue
Block a user