Files
Learn_System/frontend/js/trainer/_trainer_engine.js
T
Maxim Dolgolyov 277bddf1fd feat(trainer): P7 пошаговое решение (репетитор) + P8 мат-клавиатура
- движок checkStep(problem, line): шаг = равносильное уравнение (держится во всех корнях И не выполняется в не-корнях) → ловит арифметику, потерю корня, тождество; статусы equivalent/solved/wrong/identity/parse
- страница: тумблер «Решить по шагам» (kind solve), ввод и проверка каждого шага, список принятых шагов (KaTeX + галочка), подсказка следующего шага, завершение по solved-форме; общий onSolved; stepPref между задачами
- P8: экранная мат-клавиатура (( ) x / ^ √ ; ⌫, вставка в курсор, без либ) + live-превью KaTeX; для поля ответа и поля шага
- ROADMAP_V2: P7+P8 → DONE; смоук движка 300/300 (T14 checkStep), страница 33/33 (шаг-сценарии)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 15:06:46 +03:00

488 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/* ════════════════════════════════════════════════════════════════════════
TrainerEngine — ядро ИИ-тренажёра (Фаза 0, прототип).
Идея (гибрид): задачи рождаются из ДАННЫХ — «генераторов», а математика
считается ДЕТЕРМИНИРОВАННО через SimExpr (тот же безопасный вычислитель, что
у конструктора симуляций; ⛔ без eval/new Function). LLM в этом ядре НЕ
участвует: его роль — один раз сочинить генераторы (Уровень 0) либо позже
отдавать текстовые задачи, которые ЭТОТ ЖЕ слой верифицирует подстановкой
(Уровень 1). Любой источник задачи проходит один и тот же verifyRoot.
Генератор (данные):
{
id, skill, title,
pick: { a:[lo,hi], ... }, // целые параметры из диапазонов
constraint?: "c < a", // булево над pick (SimExpr) — иначе пересэмпл
derive?: { c: "a*root + b" }, // доп. параметры последовательно (SimExpr)
require?: "...", // булево после derive — иначе пересэмпл
lhs, rhs, // СТОРОНЫ уравнения как выражения с {param} и x
display?, // как показать (по умолч. "lhs = rhs")
answerVar?: "x", // имя неизвестной (деф. x)
answer: "root", // корень как формула над параметрами
integerAnswer?: true, // требовать целый корень
solution?: ["шаг … {ans}", …] // шаблоны шагов (доступен {ans})
}
Гарантия КОРРЕКТНОСТИ: после материализации движок ПОДСТАВЛЯЕТ заявленный
корень в уравнение (verifyRoot). Не сходится — экземпляр отбрасывается (в
strict-режиме — исключение). Та же подстановка проверяет ответ ученика
(checkStudentAnswer) и автоматически принимает эквивалентные формы
(5, 5.0, 10/2, "x=15/3", "2+3").
API (window.TrainerEngine):
instantiate(gen, opts) -> problem | null
generateBatch(gen, n, opts) -> problem[]
verifyRoot(problem, value) -> { ok, residual, lhs, rhs }
checkStudentAnswer(problem, input)-> { ok, value, residual, message, reason? }
makeRng(seed) -> () => [0,1) (детерминизм для тестов/пула)
problem:
{ genId, skill, title, lhsExpr, rhsExpr, display, answerVar, answer,
params, solution }
════════════════════════════════════════════════════════════════════════ */
(function (global) {
function SE() {
var s = global.SimExpr;
if (!s) throw new Error('TrainerEngine требует SimExpr (подключите _sim_expr.js раньше).');
return s;
}
// Допуск подстановки: масштабируется величиной сторон, чтобы крупные
// коэффициенты не давали ложного «не сходится» из-за плавающей арифметики.
var EPS = 1e-7;
/* ── Детерминированный ГПСЧ (mulberry32) — тот же, что в game/map.js ──
Нужен, чтобы предгенерация пула и тесты были воспроизводимы. В рантайме
можно не передавать seed (тогда берётся внутренний инкремент от Date нельзя —
поэтому дефолт фиксирован, а вариативность даёт сам диапазон pick). */
function makeRng(seed) {
var s = (seed >>> 0) || 1;
return function () {
s |= 0; s = (s + 0x6D2B79F5) | 0;
var t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function randInt(rng, lo, hi) { return lo + Math.floor(rng() * (hi - lo + 1)); }
/* ── Кэш компиляции выражений (рендеренные строки часто повторяются) ── */
var _cache = Object.create(null);
function compileExpr(src) {
var key = String(src);
var c = _cache[key];
if (!c) { c = SE().compile(key); _cache[key] = c; }
return c;
}
function evalExpr(src, env) { return compileExpr(src).fn(env); }
function truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; }
function isIntApprox(v) { return isFinite(v) && Math.abs(v - Math.round(v)) < 1e-9; }
function fmtNum(v) {
if (typeof v !== 'number') return String(v);
if (isIntApprox(v)) return String(Math.round(v));
return String(Math.round(v * 1e6) / 1e6);
}
/* Подстановка {name} -> значение (для выражений и подписей). */
function render(tpl, vals) {
return String(tpl).replace(/\{(\w+)\}/g, function (m, k) {
return Object.prototype.hasOwnProperty.call(vals, k) ? fmtNum(vals[k]) : m;
});
}
/* Лёгкая косметика ТОЛЬКО для показа (не для вычислений):
5*x -> 5x, «+ -» -> «− », ведущий коэффициент 1 у x убираем. */
function prettyMath(s) {
return String(s)
.replace(/(\d)\s*\*\s*(\d)/g, '$1·$2') // 4*5 -> 4·5 (число·число)
.replace(/\s*\*\s*/g, '') // 7*x -> 7x (неявное умножение)
.replace(/\+\s*-\s*/g, ' ') // + -3 -> 3
.replace(/-\s*-\s*/g, '+ ')
.replace(/(^|[(=+\-\s])1(?=x)/g, '$1'); // ведущий 1·x -> x
}
function assign(base, extra) {
var o = {}, k;
for (k in base) if (Object.prototype.hasOwnProperty.call(base, k)) o[k] = base[k];
for (k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) o[k] = extra[k];
return o;
}
/* ── Выражение -> LaTeX (через AST SimExpr) для KaTeX-рендера ──
Возвращает строку LaTeX или null, если выражение не разобралось. Покрывает
наши нужды: дроби (\frac), степени, неявное умножение, скобки по приоритету,
сравнения (= ≠ ≤ ≥), sqrt/abs/тригонометрию. Один проход AST, без eval.
Reusable: тем же конвертером можно рендерить и задачи Уровня-1 (LLM). */
function _prec(n) {
if (!n) return 9;
if (n.k === 'cmp' || n.k === 'logic') return 0;
if (n.k === 'bin') {
if (n.op === '+' || n.op === '-') return 1;
if (n.op === '*' || n.op === '/' || n.op === '%') return 2;
if (n.op === '^') return 4;
}
if (n.k === 'un' || n.k === 'not') return 3;
return 5;
}
function _isNeg(n) {
return (n.k === 'num' && n.v < 0) || (n.k === 'un' && n.op === '-') ||
(n.k === 'bin' && n.op === '*' && _isNeg(n.a)); // (-5)*x — отрицательное слагаемое
}
function _negate(n) {
if (n.k === 'num') return { k: 'num', v: -n.v };
if (n.k === 'un' && n.op === '-') return n.a;
if (n.k === 'bin' && n.op === '*') return { k: 'bin', op: '*', a: _negate(n.a), b: n.b };
return { k: 'un', op: '-', a: n };
}
function _wrapL(node, minPrec) {
var s = _latex(node);
return _prec(node) < minPrec ? '\\left(' + s + '\\right)' : s;
}
// Операнд умножения: отрицательное/унарное/сумму берём в скобки, иначе
// соседство схлопнет смысл (7*(-5) -> «7-5», 6*(x+1) -> «6x+1»).
function _mulOperand(node) {
if (_isNeg(node) || _prec(node) < 2) return '\\left(' + _latex(node) + '\\right)';
return _latex(node);
}
function _latex(node) {
switch (node.k) {
case 'num': return fmtNum(node.v);
case 'const':
if (node.v === Math.PI) return '\\pi';
if (node.v === Math.PI * 2) return '\\tau';
if (node.v === Math.E) return 'e';
return fmtNum(node.v);
case 'var': return node.name;
case 'un': return '-' + _wrapL(node.a, 3);
case 'not': return '\\lnot ' + _wrapL(node.a, 3);
case 'cmp': {
var m = { '==': '=', '!=': '\\ne', '<': '<', '<=': '\\le', '>': '>', '>=': '\\ge' };
return _latex(node.a) + ' ' + (m[node.op] || node.op) + ' ' + _latex(node.b);
}
case 'logic':
return _latex(node.a) + (node.op === '&&' ? ' \\land ' : ' \\lor ') + _latex(node.b);
case 'cond':
return _wrapL(node.c, 1) + ' \\,?\\, ' + _latex(node.a) + ' : ' + _latex(node.b);
case 'call': {
if (node.name === 'sqrt') return '\\sqrt{' + _latex(node.args[0]) + '}';
if (node.name === 'abs') return '\\left|' + _latex(node.args[0]) + '\\right|';
var TRIG = { sin: '\\sin', cos: '\\cos', tan: '\\tan', tg: '\\tan', ln: '\\ln', log: '\\log', exp: '\\exp' };
var fn = TRIG[node.name] || ('\\operatorname{' + node.name + '}');
return fn + '\\left(' + node.args.map(_latex).join(',\\, ') + '\\right)';
}
case 'bin': {
var op = node.op;
if (op === '/') return '\\frac{' + _latex(node.a) + '}{' + _latex(node.b) + '}';
if (op === '^') {
var base = _prec(node.a) < 5 ? '\\left(' + _latex(node.a) + '\\right)' : _latex(node.a);
return base + '^{' + _latex(node.b) + '}';
}
if (op === '*') {
if (_isNeg(node.a)) return '-' + _latex({ k: 'bin', op: '*', a: _negate(node.a), b: node.b }); // -5*x -> «-5x»
var sep = (node.b.k === 'num' && node.b.v >= 0) ? ' \\cdot ' : ''; // знак · между числами; иначе соседство
return _mulOperand(node.a) + sep + _mulOperand(node.b);
}
if (op === '%') return _wrapL(node.a, 2) + ' \\bmod ' + _wrapL(node.b, 3);
// + или - (схлопываем a + (-b) -> a - b и a - (-b) -> a + b)
var right = node.b, rop = op;
if (op === '+' && _isNeg(right)) { rop = '-'; right = _negate(right); }
else if (op === '-' && _isNeg(right)) { rop = '+'; right = _negate(right); }
return _wrapL(node.a, 1) + ' ' + rop + ' ' + _wrapL(right, rop === '-' ? 2 : 1);
}
}
return '';
}
function exprToLatex(src) {
var ast;
try { ast = SE().parse(String(src)); } catch (e) { return null; }
try { return _latex(ast); } catch (e2) { return null; }
}
/* ── Подстановочная верификация корня ──
Истинно, если левая и правая части совпадают при answerVar = value. */
function verifyRoot(problem, value) {
var env = {};
env[problem.answerVar || 'x'] = value;
var L = evalExpr(problem.lhsExpr, env);
var R = evalExpr(problem.rhsExpr, env);
var residual = Math.abs(L - R);
var scale = Math.max(1, Math.abs(L), Math.abs(R));
return { ok: residual <= EPS * scale, residual: residual, lhs: L, rhs: R };
}
/* ── Эквивалентность выражений численным сэмплингом ──
Истинно, если exprA и exprB совпадают в нескольких точках по переменным vars
(для проверки упрощения/раскрытия: 3x+5x ≡ 8x, a(x+b) ≡ ax+ab). Точки
фиксированы → детерминированно (без Math.random). */
var _EQUIV_PTS = [-3.7, -1.3, 0.5, 2.1, 4.9, -0.9, 3.3, 1.7];
function _sampleEquiv(exprA, exprB, vars) {
var ca = SE().compile(String(exprA)), cb = SE().compile(String(exprB));
if (ca.error || cb.error) return { ok: false, reason: 'parse' };
vars = (vars && vars.length) ? vars : ['x'];
for (var i = 0; i < _EQUIV_PTS.length; i++) {
var env = {};
for (var v = 0; v < vars.length; v++) env[vars[v]] = _EQUIV_PTS[(i + v * 3) % _EQUIV_PTS.length];
var a = ca.fn(env), b = cb.fn(env);
var scale = Math.max(1, Math.abs(a), Math.abs(b));
if (Math.abs(a - b) > 1e-6 * scale) return { ok: false };
}
return { ok: true };
}
/* ── Материализация одного экземпляра ──
Возвращает problem или null, если за maxTries не удалось выполнить
ограничения / целочисленность / самопроверку. */
function instantiate(gen, opts) {
opts = opts || {};
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
var maxTries = opts.maxTries || 300;
var answerVar = gen.answerVar || 'x';
for (var attempt = 0; attempt < maxTries; attempt++) {
var env = {};
var pk = gen.pick || {}, k;
for (k in pk) if (Object.prototype.hasOwnProperty.call(pk, k)) {
env[k] = randInt(rng, pk[k][0], pk[k][1]);
}
if (gen.constraint && !truthy(evalExpr(gen.constraint, env))) continue;
if (gen.derive) {
for (k in gen.derive) if (Object.prototype.hasOwnProperty.call(gen.derive, k)) {
env[k] = evalExpr(gen.derive[k], env);
}
}
if (gen.require && !truthy(evalExpr(gen.require, env))) continue;
var kind = gen.kind || 'solve';
// корни: одиночный (answer) или множественный (answers — массив выражений)
var answers = null;
if (Array.isArray(gen.answers)) {
answers = gen.answers.map(function (a) { return evalExpr(a, env); });
if (gen.integerAnswer) answers = answers.map(function (x) { return Math.round(x); });
}
var answer = gen.answer ? evalExpr(gen.answer, env) : (answers ? answers[0] : 0);
if (gen.answer && gen.integerAnswer) {
if (!isIntApprox(answer)) continue;
answer = Math.round(answer);
}
var lhsExpr = render(gen.lhs || 'x', env);
var rhsExpr = render(gen.rhs || 'x', env);
var sEnv = assign(env, { ans: answer });
// latex уравнения строим только для уравнений (solve/roots); compute/simplify —
// текстовый prompt из display.
var showEq = (kind === 'solve' || kind === 'roots');
var ll = showEq ? exprToLatex(lhsExpr) : null;
var rl = showEq ? exprToLatex(rhsExpr) : null;
var answerExpr = gen.answerExpr ? render(gen.answerExpr, env) : null;
var problem = {
genId: gen.id,
skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан
title: gen.title,
kind: kind,
lhsExpr: lhsExpr,
rhsExpr: rhsExpr,
display: prettyMath(render(gen.display || (gen.lhs + ' = ' + gen.rhs), env)),
latex: (ll != null && rl != null) ? (ll + ' = ' + rl) : null,
answerVar: answerVar,
answer: answer,
answers: answers, // массив корней (kind roots)
answerExpr: answerExpr, // канон. выражение (kind simplify)
answerVars: gen.answerVars || [answerVar],
params: env,
// шаг решения -> { note(текст), tex(подпись), latex(для KaTeX, null если не разобрался) }
// строковый шаг (легаси) трактуется как чистая заметка без формулы.
solution: (gen.solution || []).map(function (st) {
if (typeof st === 'string') return { note: render(st, sEnv), tex: '', latex: null };
var texSrc = st.tex ? render(st.tex, sEnv) : '';
return {
note: st.note ? render(st.note, sEnv) : '',
tex: texSrc ? prettyMath(texSrc) : '',
latex: texSrc ? exprToLatex(texSrc) : null
};
})
};
// Самопроверка по типу: simplify → эквивалентность; roots → все корни; иначе → корень.
var okSelf, why;
if (kind === 'simplify') {
okSelf = _sampleEquiv(render(gen.srcExpr || gen.lhs || 'x', env), answerExpr, problem.answerVars).ok;
why = 'упрощение не эквивалентно ответу';
} else if (answers) {
okSelf = answers.every(function (r) { return verifyRoot(problem, r).ok; });
why = 'не все корни удовлетворяют уравнению';
} else {
var v = verifyRoot(problem, answer);
okSelf = v.ok; why = 'корень ' + fmtNum(answer) + ' не удовлетворяет (невязка ' + v.residual + ')';
}
if (!okSelf) {
if (opts.strict) throw new Error('Генератор «' + gen.id + '»: ' + why + '.');
continue;
}
return problem;
}
return null;
}
/* ── Пакет из n различных по виду задач ── */
function generateBatch(gen, n, opts) {
opts = opts || {};
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
var out = [], seen = Object.create(null);
var guard = n * 20 + 50;
while (out.length < n && guard-- > 0) {
var p = instantiate(gen, { rng: rng, strict: opts.strict, maxTries: opts.maxTries });
if (!p) break;
if (seen[p.display]) continue;
seen[p.display] = 1;
out.push(p);
}
return out;
}
/* ── Проверка ответа ученика ──
Принимает строку/число. SimExpr.compile сам срезает ведущее «x=», поэтому
"x = 5", "5", "10/2", "2+3" нормализуются к числу. Верно, если значение
удовлетворяет уравнению (эквивалентные формы проходят) ИЛИ совпадает с
эталонным корнем (страховка единственности для будущих многокорневых типов). */
function checkStudentAnswer(problem, input) {
var raw = String(input == null ? '' : input).trim();
if (!raw) return { ok: false, reason: 'empty', value: null, residual: null, message: 'Введите ответ.' };
if (problem.kind === 'simplify') return _checkEquiv(problem, raw);
if (problem.kind === 'roots') return _checkMultiRoot(problem, raw);
var c = SE().compile(raw);
if (c.error) {
return { ok: false, reason: 'parse', value: null, residual: null,
message: 'Не понял ответ: ' + c.error };
}
var val = c.fn({});
if (!isFinite(val)) {
return { ok: false, reason: 'nan', value: val, residual: null, message: 'Это не число.' };
}
var v = verifyRoot(problem, val);
var nearCanonical = Math.abs(val - problem.answer) <= 1e-6 * Math.max(1, Math.abs(problem.answer));
var ok = v.ok || nearCanonical;
return {
ok: ok, reason: ok ? null : 'wrong', value: val, residual: v.residual,
message: ok ? 'Верно!' : 'Пока неверно.'
};
}
/* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */
function _checkMultiRoot(problem, raw) {
var parts = raw.split(/[;,\s]+/).filter(Boolean);
if (!parts.length) return { ok: false, reason: 'empty', message: 'Введите ответ.' };
var vals = [];
for (var i = 0; i < parts.length; i++) {
var c = SE().compile(parts[i]);
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял ответ.' };
var x = c.fn({});
if (!isFinite(x)) return { ok: false, reason: 'nan', message: 'Это не число.' };
vals.push(x);
}
var want = (problem.answers || []).slice();
if (vals.length !== want.length) return { ok: false, reason: 'count', message: 'Укажите все корни через «;».' };
var used = want.map(function () { return false; });
for (var j = 0; j < vals.length; j++) {
var f = -1;
for (var w = 0; w < want.length; w++) {
if (!used[w] && Math.abs(vals[j] - want[w]) <= 1e-6 * Math.max(1, Math.abs(want[w]))) { f = w; break; }
}
if (f < 0) return { ok: false, reason: 'wrong', message: 'Пока неверно.' };
used[f] = true;
}
return { ok: true, reason: null, message: 'Верно!' };
}
/* Упрощение: ответ-выражение проверяем на эквивалентность сэмплингом. */
function _checkEquiv(problem, raw) {
var c = SE().compile(raw);
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял выражение: ' + c.error };
var se = _sampleEquiv(raw, problem.answerExpr, problem.answerVars || ['x']);
return { ok: se.ok, reason: se.ok ? null : (se.reason || 'wrong'), value: raw, message: se.ok ? 'Верно!' : 'Пока неверно.' };
}
/* ── Пошаговое решение (репетитор): проверка одного шага-равенства ──
Шаг = равносильное уравнение (то же множество корней). Идея без решения
уравнений: уравнение L=R равносильно исходному ⟺ выполняется во ВСЕХ корнях
и НЕ выполняется в точках-не-корнях (то есть сужает x именно до корней).
Ловит арифметику (не держится в корне), потерю корня и тождество «0=0». */
function _splitEq(s) {
var i = String(s).indexOf('=');
if (i <= 0 || i >= s.length - 1) return null;
if (s.indexOf('=', i + 1) !== -1) return null; // нет цепочек a=b=c и составных ==,<=,>=
return [s.slice(0, i).trim(), s.slice(i + 1).trim()];
}
function _isConst(c, v) {
var e1 = {}, e2 = {}; e1[v] = 1.3; e2[v] = 2.7;
return Math.abs(c.fn(e1) - c.fn(e2)) < 1e-9;
}
function _isVarOnly(s, v) { return String(s).replace(/\s+/g, '') === v; }
function _isSolvedForm(lhs, rhs, v, roots) {
var cl = SE().compile(lhs), cr = SE().compile(rhs);
if (cl.error || cr.error) return false;
var lv = _isVarOnly(lhs, v), rv = _isVarOnly(rhs, v);
if (lv && _isConst(cr, v)) { var a = cr.fn({}); return roots.some(function (r) { return Math.abs(a - r) <= 1e-6; }); }
if (rv && _isConst(cl, v)) { var b = cl.fn({}); return roots.some(function (r) { return Math.abs(b - r) <= 1e-6; }); }
return false;
}
function checkStep(problem, line) {
var raw = String(line == null ? '' : line).trim();
if (!raw) return { ok: false, status: 'empty', message: 'Введите шаг — равенство со знаком «=».' };
var parts = _splitEq(raw);
if (!parts) return { ok: false, status: 'parse', message: 'Шаг — это одно равенство со знаком «=».' };
var cl = SE().compile(parts[0]), cr = SE().compile(parts[1]);
if (cl.error || cr.error) return { ok: false, status: 'parse', message: 'Не понял выражение в шаге.' };
var v = problem.answerVar || 'x';
var roots = (problem.answers && problem.answers.length) ? problem.answers : [problem.answer];
// держится во всех корнях?
for (var i = 0; i < roots.length; i++) {
var env = {}; env[v] = roots[i];
var L = cl.fn(env), R = cr.fn(env);
if (Math.abs(L - R) > 1e-7 * Math.max(1, Math.abs(L), Math.abs(R)))
return { ok: false, status: 'wrong', message: 'Не равносильно: при ' + v + ' = ' + fmtNum(roots[i]) + ' равенство не выполняется.' };
}
// сужает x до корней? (в не-корнях должно НЕ выполняться)
var total = 0, holds = 0;
for (var j = 0; j < _EQUIV_PTS.length; j++) {
var x = _EQUIV_PTS[j];
if (roots.some(function (r) { return Math.abs(x - r) < 1e-6; })) continue;
total++; var e2 = {}; e2[v] = x;
var L2 = cl.fn(e2), R2 = cr.fn(e2);
if (Math.abs(L2 - R2) <= 1e-7 * Math.max(1, Math.abs(L2), Math.abs(R2))) holds++;
}
if (total > 0 && holds === total)
return { ok: false, status: 'identity', message: 'Это тождество — верно при любом ' + v + ' и не приближает к ответу.' };
var done = _isSolvedForm(parts[0], parts[1], v, roots);
return { ok: true, status: done ? 'solved' : 'equivalent', message: done ? 'Готово!' : 'Верный шаг.' };
}
global.TrainerEngine = {
instantiate: instantiate,
generateBatch: generateBatch,
verifyRoot: verifyRoot,
checkStudentAnswer: checkStudentAnswer,
checkStep: checkStep,
makeRng: makeRng,
// мелочи наружу для билдера/тестов
render: render,
prettyMath: prettyMath,
exprToLatex: exprToLatex
};
})(typeof window !== 'undefined' ? window : globalThis);