Files
Learn_System/frontend/js/trainer/_trainer_engine.js
T
Maxim Dolgolyov fb81beca39 feat(trainer): разбор типовых ошибок (репетитор C1, движок)
- TrainerEngine.analyzeMistake(problem, value) -> {type, hint} | null: по неверному числовому ответу распознаёт типовую ошибку и даёт адресную подсказку, НЕ выдавая ответ
- solve: уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам (без структуры генератора) -> ловит «забыл разделить на коэффициент»
- общие эвристики: перепутан знак (value≈-correct), близкая арифметическая ошибка (|Δ|≤20%), иначе generic
- работает для solve/compute; пара/корни/неравенство пропускаются
- смоук движка 825/825 (T20: nodivide/sign/arith/generic/null)
- страница НЕ тронута (редизайн в параллельной сессии); показ подсказки на неверном ответе подключу на странице вместе с полировкой ввода систем после редизайна

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

663 lines
37 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)); }
/* ── Уровни сложности: масштабирование диапазона pick ──
level 2 — базовый (как задано); 1 — легче (меньше магнитуды, меньше
отрицательных); 3 — сложнее (шире магнитуды). Универсально для всех
генераторов; корректность держит «корень-вперёд» + самопроверка. */
function _scaleRange(r, level) {
var lo = r[0], hi = r[1];
if (!level || level === 2) return [lo, hi];
if (level === 1) {
var nlo = lo < 0 ? Math.ceil(lo / 2) : lo;
var nhi = hi > 0 ? Math.max(nlo + 1, Math.round(hi / 2)) : hi;
return [nlo, nhi];
}
var elo = lo < 0 ? Math.floor(lo * 1.8) : lo;
var ehi = Math.round(hi * 1.8);
if (ehi <= elo) ehi = elo + 1;
return [elo, ehi];
}
/* ── Кэш компиляции выражений (рендеренные строки часто повторяются) ── */
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 === '*') {
// единичный коэффициент: 1*x -> x, (-1)*x -> -x (только при не-числовом множителе)
if (node.a.k === 'num' && Math.abs(node.a.v) === 1 && node.b.k !== 'num')
return (node.a.v < 0 ? '-' : '') + _mulOperand(node.b);
if (node.b.k === 'num' && Math.abs(node.b.v) === 1 && node.a.k !== 'num')
return (node.b.v < 0 ? '-' : '') + _mulOperand(node.a);
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;
var lvl = opts.level;
for (k in pk) if (Object.prototype.hasOwnProperty.call(pk, k)) {
var rk = (lvl && !gen.noScale) ? _scaleRange(pk[k], lvl) : pk[k];
env[k] = randInt(rng, rk[0], rk[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);
}
// система уравнений (kind system): набор строк + пара-ответ {x,y,...}
var system = null, pair = null;
if (kind === 'system') {
system = (gen.eqs || []).map(function (e) { return { lhs: render(e.lhs, env), rhs: render(e.rhs, env) }; });
pair = {};
var avs = gen.answerVars || ['x', 'y'];
for (var ai = 0; ai < avs.length; ai++) {
var pv = evalExpr((gen.answers && gen.answers[avs[ai]]) || '0', env);
pair[avs[ai]] = gen.integerAnswer ? Math.round(pv) : pv;
}
answer = pair[avs[0]]; // запасной одиночный ответ
}
var lhsExpr = render(gen.lhs || 'x', env);
var rhsExpr = render(gen.rhs || 'x', env);
var sEnv = assign(env, { ans: answer });
var answerExpr = gen.answerExpr ? render(gen.answerExpr, env) : null;
var answerRel = (kind === 'inequality') ? { op: gen.relOp || '<', bound: evalExpr(gen.bound, env) } : null;
// latex: уравнение (solve/roots) | выражение (simplify) | неравенство (inequality)
// | null (compute → текстовый prompt из display).
var latex = null;
if (kind === 'solve' || kind === 'roots') {
var ll = exprToLatex(lhsExpr), rl = exprToLatex(rhsExpr);
if (ll != null && rl != null) latex = ll + ' = ' + rl;
} else if (kind === 'simplify' && gen.srcExpr) {
latex = exprToLatex(render(gen.srcExpr, env));
} else if (kind === 'inequality') {
latex = exprToLatex(lhsExpr + ' ' + (gen.dispOp || '<') + ' ' + rhsExpr);
} else if (kind === 'system' && system) {
var rows = [], okrows = true;
for (var si2 = 0; si2 < system.length; si2++) {
var l2 = exprToLatex(system[si2].lhs), r2 = exprToLatex(system[si2].rhs);
if (l2 == null || r2 == null) { okrows = false; break; }
rows.push(l2 + ' = ' + r2);
}
if (okrows) latex = '\\begin{cases} ' + rows.join(' \\\\ ') + ' \\end{cases}';
}
var problem = {
genId: gen.id,
skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан
title: gen.title,
kind: kind,
lhsExpr: lhsExpr,
rhsExpr: rhsExpr,
display: (kind === 'system' && system)
? system.map(function (e) { return prettyMath(e.lhs + ' = ' + e.rhs); }).join('; ')
: prettyMath(render(gen.display || (gen.lhs + (kind === 'inequality' ? (' ' + (gen.dispOp || '<') + ' ') : ' = ') + gen.rhs), env)),
latex: latex,
answerVar: answerVar,
answer: answer,
answers: answers, // массив корней (kind roots)
answerExpr: answerExpr, // канон. выражение (kind simplify)
answerRel: answerRel, // { op, bound } (kind inequality)
system: system, // [{lhs,rhs},…] (kind system)
pair: pair, // эталонная пара {x,y,…} (kind system)
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 (kind === 'inequality') {
var bnd = answerRel.bound, iop = answerRel.op;
var inside = (iop === '<' || iop === '<=') ? bnd - 1 : bnd + 1;
var outside = (iop === '<' || iop === '<=') ? bnd + 1 : bnd - 1;
okSelf = _origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, inside) &&
!_origIneqHolds(lhsExpr, rhsExpr, gen.dispOp || '<', answerVar, outside);
why = 'неравенство не согласовано с ответом';
} else if (kind === 'system') {
okSelf = !!(system && system.length) && system.every(function (e) {
var L = evalExpr(e.lhs, pair), R = evalExpr(e.rhs, pair);
return Math.abs(L - R) <= EPS * Math.max(1, Math.abs(L), Math.abs(R));
});
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, level: opts.level });
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);
if (problem.kind === 'inequality') return _checkInequality(problem, raw);
if (problem.kind === 'system') return _checkSystem(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 ? 'Верно!' : 'Пока неверно.'
};
}
/* ── Разбор типовой ошибки ученика (репетитор, направление C) ──
По неверному ЧИСЛОВОМУ ответу пытается распознать типовую ошибку и дать
адресную подсказку, НЕ выдавая правильный ответ. Работает для solve/compute.
Для solve уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам
(без структуры генератора) → ловим «забыл разделить на коэффициент». Плюс
общие эвристики: перепутан знак, близкая арифметическая ошибка.
Возвращает { type, hint } или null (ошибка не распознана / ответ верный). */
function _linAB(problem) {
var av = problem.answerVar || 'x';
var e0 = {}, e1 = {}; e0[av] = 0; e1[av] = 1;
var g0 = evalExpr(problem.lhsExpr, e0) - evalExpr(problem.rhsExpr, e0);
var g1 = evalExpr(problem.lhsExpr, e1) - evalExpr(problem.rhsExpr, e1);
if (!isFinite(g0) || !isFinite(g1)) return null;
return { A: g1 - g0, B: g0 }; // f(x) = A·x + B, корень = -B/A
}
function analyzeMistake(problem, value) {
if (!problem || !isFinite(value)) return null;
var kind = problem.kind || 'solve';
if (kind !== 'solve' && kind !== 'compute') return null; // пара/корни/неравенство — отдельно
var correct = problem.answer;
var tol = 1e-6 * Math.max(1, Math.abs(correct));
if (Math.abs(value - correct) <= tol) return null; // на самом деле верно
// структурно: линейное уравнение → «забыл разделить на коэффициент»
if (kind === 'solve') {
var ab = _linAB(problem);
if (ab && Math.abs(ab.A) > 1.5) {
var noDivide = -ab.B; // значение на шаге «A·x = -B», ещё не делённое на A (= A·correct)
if (Math.abs(value - noDivide) <= Math.max(tol, 1e-6 * Math.abs(noDivide)))
return { type: 'nodivide', hint: 'Похоже, ты не разделил обе части на коэффициент при переменной (' + fmtNum(ab.A) + '). Раздели — и получишь ответ.' };
}
}
// перепутан знак ответа
if (correct !== 0 && Math.abs(value + correct) <= Math.max(tol, 1e-6 * Math.abs(correct)))
return { type: 'sign', hint: 'Кажется, перепутан знак. Проверь знаки при переносе слагаемых через знак «=».' };
// близкая арифметическая ошибка
if (Math.abs(value - correct) <= Math.max(1, Math.abs(correct) * 0.2))
return { type: 'arith', hint: 'Очень близко — похоже на арифметическую ошибку в вычислениях. Пересчитай аккуратно.' };
return { type: 'generic', hint: 'Разбери решение по шагам и попробуй похожую задачу.' };
}
/* Система: ученик вводит пару «x = 2; y = 3» (или «2; 3»). Проверяем подстановкой в ОБА уравнения.
Метки переменных опциональны; без меток — по порядку answerVars. */
function _checkSystem(problem, raw) {
var vars = problem.answerVars || ['x', 'y'];
var parts = raw.split(/[;,]/).map(function (s) { return s.trim(); }).filter(Boolean);
if (parts.length < vars.length) return { ok: false, reason: 'incomplete', message: 'Введите обе переменные, напр. x = 2; y = 3.' };
var vals = {}, pos = [];
for (var i = 0; i < parts.length; i++) {
var m = parts[i].match(/^([a-zA-Z]\w*)\s*=\s*(.+)$/);
var c = SE().compile(m ? m[2] : parts[i]);
if (c.error) return { ok: false, reason: 'parse', message: 'Не понял запись «' + parts[i] + '».' };
var num = c.fn({});
if (!isFinite(num)) return { ok: false, reason: 'nan', message: 'Это не число.' };
if (m) vals[m[1]] = num; else pos.push(num);
}
for (var j = 0; j < vars.length; j++) if (vals[vars[j]] === undefined && pos.length) vals[vars[j]] = pos.shift();
for (var j2 = 0; j2 < vars.length; j2++) if (vals[vars[j2]] === undefined) return { ok: false, reason: 'incomplete', message: 'Укажите ' + vars[j2] + '.' };
var sys = problem.system || [];
for (var e = 0; e < sys.length; e++) {
var L = evalExpr(sys[e].lhs, vals), R = evalExpr(sys[e].rhs, vals);
if (Math.abs(L - R) > EPS * Math.max(1, Math.abs(L), Math.abs(R)))
return { ok: false, reason: 'wrong', value: vals, message: 'Пара не подходит под уравнения системы.' };
}
return { ok: true, reason: null, value: vals, message: 'Верно!' };
}
/* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */
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 ? 'Верно!' : 'Пока неверно.' };
}
/* ── Неравенства: проверка ответа-отношения «x < c» ──
Парсим отношение ученика, нормализуем к виду «x op c» (переменная слева;
если справа — отношение переворачивается), сравниваем op и границу. */
function _origIneqHolds(lhsExpr, rhsExpr, op, v, xv) {
var env = {}; env[v] = xv;
var L = evalExpr(lhsExpr, env), R = evalExpr(rhsExpr, env);
switch (op) {
case '<': return L < R; case '>': return L > R;
case '<=': return L <= R; case '>=': return L >= R;
}
return false;
}
function _parseRel(raw, v) {
var s = String(raw).replace(/≤/g, '<=').replace(/≥/g, '>=').replace(/\s+/g, '');
var m = s.match(/<=|>=|<|>/);
if (!m) return null;
var op = m[0], left = s.slice(0, m.index), right = s.slice(m.index + op.length);
if (!left || !right) return null;
var cl = SE().compile(left), cr = SE().compile(right);
if (cl.error || cr.error) return null;
var flip = { '<': '>', '>': '<', '<=': '>=', '>=': '<=' };
if (left === v && right !== v && _isConst(cr, v)) { var b = cr.fn({}); return isFinite(b) ? { op: op, bound: b } : null; }
if (right === v && left !== v && _isConst(cl, v)) { var b2 = cl.fn({}); return isFinite(b2) ? { op: flip[op], bound: b2 } : null; }
return null;
}
function _checkInequality(problem, raw) {
var v = problem.answerVar || 'x';
var rel = _parseRel(raw, v);
if (!rel) return { ok: false, reason: 'parse', message: 'Ответ — неравенство, напр. ' + v + ' < 3.' };
var want = problem.answerRel || {};
var ok = rel.op === want.op && Math.abs(rel.bound - want.bound) <= 1e-6 * Math.max(1, Math.abs(want.bound));
return { ok: ok, reason: ok ? null : 'wrong', value: raw, message: 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,
analyzeMistake: analyzeMistake,
checkStep: checkStep,
makeRng: makeRng,
// мелочи наружу для билдера/тестов
render: render,
prettyMath: prettyMath,
exprToLatex: exprToLatex
};
})(typeof window !== 'undefined' ? window : globalThis);