feat(trainer): P5 — несколько корней, эквивалентность выражений, новые темы

- движок: gen.answers → несколько корней (_checkMultiRoot, ввод через «;», сверка мультимножеством)
- kind simplify: эквивалентность выражений численным сэмплингом (_sampleEquiv, _checkEquiv), фикс. точки без Math.random
- exprToLatex: знаковые коэффициенты — -5x, x²−5x+6, a−(−b)→a+b (вынос ведущего минуса, схлопывание)
- темы: Упрощение (подобные, скобки) + Квадратные (Виета x²+bx+c=0, разность квадратов) → 17 генераторов, 5 тем
- страница: префикс «x=»/подсказка ввода и ответ-лейбл по типу задачи
- смоук движка 291/291 (T11 roots, T12 simplify, T13 latex), страница 26/26, adaptive 12/12; план P5 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 14:15:21 +03:00
parent 8c4c9bf04c
commit 7cc2a9d526
4 changed files with 204 additions and 28 deletions
+105 -20
View File
@@ -128,8 +128,16 @@
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 === '-'); }
function _negate(n) { return n.k === 'num' ? { k: 'num', v: -n.v } : n.a; }
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;
@@ -174,13 +182,15 @@
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 и 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);
}
}
@@ -204,6 +214,25 @@
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 не удалось выполнить
ограничения / целочисленность / самопроверку. */
@@ -230,32 +259,44 @@
if (gen.require && !truthy(evalExpr(gen.require, env))) continue;
var answer = evalExpr(gen.answer, env);
if (gen.integerAnswer) {
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, env);
var rhsExpr = render(gen.rhs, env);
var lhsExpr = render(gen.lhs || 'x', env);
var rhsExpr = render(gen.rhs || 'x', env);
var sEnv = assign(env, { ans: answer });
// compute-задача (проценты): показываем текстовый prompt из display, а
// уравнение lhs=rhs служит лишь для проверки → latex уравнения не строим.
var isCompute = gen.kind === 'compute';
var ll = isCompute ? null : exprToLatex(lhsExpr);
var rl = isCompute ? null : exprToLatex(rhsExpr);
// 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: gen.kind || 'solve',
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 если не разобрался) }
// строковый шаг (легаси) трактуется как чистая заметка без формулы.
@@ -270,13 +311,20 @@
})
};
// Самопроверка: эталонный корень ОБЯЗАН удовлетворять уравнению.
var v = verifyRoot(problem, answer);
if (!v.ok) {
if (opts.strict) {
throw new Error('Генератор «' + gen.id + '»: корень ' + fmtNum(answer) +
' не удовлетворяет уравнению (невязка ' + v.residual + ').');
}
// Самопроверка по типу: 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;
@@ -309,6 +357,9 @@
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,
@@ -328,6 +379,40 @@
};
}
/* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */
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 ? 'Верно!' : 'Пока неверно.' };
}
global.TrainerEngine = {
instantiate: instantiate,
generateBatch: generateBatch,