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:
@@ -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,
|
||||
|
||||
@@ -23,7 +23,9 @@
|
||||
var TOPICS = [
|
||||
{ key: 'linear-eq', label: 'Уравнения', subject: 'algebra', grade: 7, order: 1 },
|
||||
{ key: 'proportions', label: 'Пропорции', subject: 'algebra', grade: 7, order: 2 },
|
||||
{ key: 'percents', label: 'Проценты', subject: 'algebra', grade: 7, order: 3 }
|
||||
{ key: 'percents', label: 'Проценты', subject: 'algebra', grade: 7, order: 3 },
|
||||
{ key: 'simplify', label: 'Упрощение', subject: 'algebra', grade: 7, order: 4 },
|
||||
{ key: 'quadratic', label: 'Квадратные', subject: 'algebra', grade: 8, order: 5 }
|
||||
];
|
||||
|
||||
var GENERATORS = [
|
||||
@@ -249,6 +251,67 @@
|
||||
{ note: 'Известно, что {p}% некоторого числа равны {a}. Значит само число во столько раз больше: умножаем {a} на 100 и делим на {p}.', tex: 'x = {a}*100/{p}' },
|
||||
{ note: 'Считаем — получаем искомое число.', tex: 'x = {ans}' }
|
||||
]
|
||||
},
|
||||
|
||||
/* ═══ Тема: Упрощение выражений (проверка эквивалентностью) ═══ */
|
||||
|
||||
/* a·x + b·x → (a+b)x */
|
||||
{
|
||||
id: 'simp-like', topic: 'simplify', order: 1, subject: 'algebra', grade: 7, kind: 'simplify',
|
||||
title: 'Привести подобные',
|
||||
pick: { a: [2, 9], b: [2, 9] },
|
||||
derive: { s: 'a + b' },
|
||||
srcExpr: '{a}*x + {b}*x', answerExpr: '{s}*x', answerVars: ['x'],
|
||||
display: 'Упростите: {a}x + {b}x',
|
||||
solution: [
|
||||
{ note: 'Оба слагаемых содержат x — это подобные слагаемые. Складываем их коэффициенты: {a} + {b} = {s}.', tex: '{a}x + {b}x = {s}x' }
|
||||
]
|
||||
},
|
||||
|
||||
/* a(x + b) → ax + ab */
|
||||
{
|
||||
id: 'simp-expand', topic: 'simplify', order: 2, subject: 'algebra', grade: 7, kind: 'simplify',
|
||||
title: 'Раскрыть скобки',
|
||||
pick: { a: [2, 9], b: [1, 9] },
|
||||
derive: { ab: 'a*b' },
|
||||
srcExpr: '{a}*(x + {b})', answerExpr: '{a}*x + {ab}', answerVars: ['x'],
|
||||
display: 'Раскройте скобки: {a}(x + {b})',
|
||||
solution: [
|
||||
{ note: 'Умножаем множитель {a} на каждое слагаемое внутри скобки.', tex: '{a}(x + {b}) = {a}x + {ab}' }
|
||||
]
|
||||
},
|
||||
|
||||
/* ═══ Тема: Квадратные уравнения (несколько корней) ═══ */
|
||||
|
||||
/* x² + bx + c = 0 — разложение по Виета (два корня r1, r2) */
|
||||
{
|
||||
id: 'quad-factored', topic: 'quadratic', order: 1, subject: 'algebra', grade: 8, kind: 'roots',
|
||||
title: 'x² + bx + c = 0',
|
||||
pick: { r1: [-7, 7], r2: [-7, 7] },
|
||||
constraint: 'r1 != r2',
|
||||
derive: { b: '-(r1 + r2)', c: 'r1*r2' },
|
||||
lhs: 'x^2 + {b}*x + {c}', rhs: '0',
|
||||
answerVar: 'x', answers: ['r1', 'r2'], integerAnswer: true,
|
||||
solution: [
|
||||
{ note: 'Квадратное уравнение приравнено к нулю. По теореме Виета ищем два числа: их сумма равна {r1}+{r2}, произведение — {c}. Это и есть корни. Раскладываем на множители:', tex: '(x - {r1})(x - {r2}) = 0' },
|
||||
{ note: 'Произведение равно нулю, когда обнуляется множитель. Первый корень:', tex: 'x = {r1}' },
|
||||
{ note: 'Второй корень:', tex: 'x = {r2}' }
|
||||
]
|
||||
},
|
||||
|
||||
/* x² − a² = 0 — разность квадратов (корни ±a) */
|
||||
{
|
||||
id: 'quad-diff', topic: 'quadratic', order: 2, subject: 'algebra', grade: 8, kind: 'roots',
|
||||
title: 'x² − a² = 0',
|
||||
pick: { a: [2, 9] },
|
||||
derive: { a2: 'a*a' },
|
||||
lhs: 'x^2 - {a2}', rhs: '0',
|
||||
answerVar: 'x', answers: ['a', '-a'], integerAnswer: true,
|
||||
solution: [
|
||||
{ note: 'Слева — разность квадратов: x² − {a2} = (x − {a})(x + {a}). Раскладываем:', tex: '(x - {a})(x + {a}) = 0' },
|
||||
{ note: 'Первый корень:', tex: 'x = {a}' },
|
||||
{ note: 'Второй корень:', tex: 'x = -{a}' }
|
||||
]
|
||||
}
|
||||
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user