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,
+64 -1
View File
@@ -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}' }
]
}
];
+23 -5
View File
@@ -141,7 +141,7 @@
<main class="sb-content">
<div class="tr-wrap">
<div class="tr-head">
<h1 class="tr-h1">Тренажёр<span class="tr-pill" id="tr-subject">Алгебра · 7 класс</span></h1>
<h1 class="tr-h1">Тренажёр<span class="tr-pill" id="tr-subject">Алгебра · 78 класс</span></h1>
<div class="tr-sub">Задачи генерируются автоматически и проверяются мгновенно. Решай по одной — бесконечно.</div>
</div>
@@ -161,7 +161,7 @@
<div class="tr-eq" id="tr-eq"></div>
<div class="tr-inrow">
<span class="tr-eqx">x =</span>
<span class="tr-eqx" id="tr-eqx">x =</span>
<input class="tr-input" id="tr-input" type="text" inputmode="text" autocomplete="off"
placeholder="ответ" aria-label="Ваш ответ"/>
<button class="tr-btn tr-primary" id="tr-check" type="button">Проверить</button>
@@ -299,6 +299,7 @@
cur = wordPool[wordIdx % wordPool.length]; wordIdx++;
$('tr-skill').textContent = cur.title;
setMath(eq, null, cur.display, true); // условие как текст
applyInputMode();
var inp = $('tr-input'); inp.value = ''; inp.disabled = false;
setMode(false); inp.focus();
}
@@ -362,6 +363,19 @@
answered = done;
$('tr-check').textContent = done ? 'Дальше' : 'Проверить';
}
// Префикс «x =» и подсказка ввода зависят от типа задачи.
function applyInputMode() {
var k = cur && cur.kind;
var multi = (k === 'roots' || k === 'simplify');
var eqx = $('tr-eqx'); if (eqx) eqx.style.display = multi ? 'none' : '';
$('tr-input').placeholder = (k === 'roots') ? 'корни через ;' : (k === 'simplify') ? 'упрощённое выражение' : 'ответ';
}
// Текст ответа в фидбеке/раскрытии — по типу задачи.
function answerLabel() {
if (cur.kind === 'roots' && cur.answers) return 'Корни: ' + cur.answers.map(fmt).join('; ');
if (cur.kind === 'simplify') return '= ' + (cur.answerExpr ? fmt(cur.answerExpr) : '');
return 'x = ' + fmt(cur.answer);
}
function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; }
function stepHtml(st, n) {
@@ -384,8 +398,9 @@
$('tr-skill').textContent = curGen.title;
var eq = $('tr-eq');
eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты) — другим шрифтом
eq.classList.toggle('tr-eq-text', !cur.latex); // текстовый prompt (проценты/упрощение) — другим шрифтом
setMath(eq, cur.latex, cur.display, true);
applyInputMode();
var inp = $('tr-input');
inp.value = ''; inp.disabled = false;
var fb = $('tr-feedback'); fb.className = 'tr-feedback'; fb.textContent = '';
@@ -467,7 +482,8 @@
streak = 0;
$('tr-input').disabled = true;
var fb = $('tr-feedback'); fb.className = 'tr-feedback';
setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false);
if (cur.kind === 'roots' || cur.kind === 'simplify') fb.textContent = 'Ответ: ' + answerLabel();
else setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false);
setMode(true);
recordAnswer(false); submitAttempt(false);
updateStats();
@@ -486,7 +502,9 @@
if (r.ok) {
solved++; streak++;
fb.className = 'tr-feedback ok';
fb.innerHTML = ICON.ok + ' <span>Верно!</span>&nbsp;' + (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
var lbl = (cur.kind === 'roots' || cur.kind === 'simplify') ? esc(answerLabel())
: (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
fb.innerHTML = ICON.ok + ' <span>Верно!</span>&nbsp;' + lbl;
recordAnswer(true); submitAttempt(true);
} else {
streak = 0;
+12 -2
View File
@@ -108,9 +108,19 @@ practice.test.js 11/11 (+SR box/due).
- **Acceptance:** учитель собирает рабочий генератор без кода; ученик решает; права/видимость
как у custom-sim (own + раздано).
## Phase 5 — Типы ответов и проверки
## Phase 5 — Типы ответов и проверки — DONE (частично)
**Цель:** не только «корень-число».
**Сделано:** движок получил **несколько корней** (`gen.answers``problem.answers`;
`_checkMultiRoot` — ввод всех корней через «;», сверка мультимножеством) и
**эквивалентность выражений** (`kind:'simplify'`, `gen.srcExpr`/`answerExpr`;
`_sampleEquiv` — численный сэмплинг в фикс. точках, без Math.random; `_checkEquiv`).
`exprToLatex` чинит знаковые коэффициенты (`-5x`, `x²−5x+6`, `a(b)→a+b`). Новые
темы: **Упрощение** (привести подобные, раскрыть скобки) и **Квадратные** (Виета
`x²+bx+c=0`, разность квадратов — 2 корня). Страница: префикс «x=» и подсказка ввода
по типу, ответ-лейбл (корни/выражение). Смоук движка 291/291 (T11 roots, T12 simplify,
T13 latex). **Осталось (стретч):** неравенства (нужен парсер отношений) — не вошло.
**Цель (исходная):** не только «корень-число».
- Множество корней (квадратные/факторизация), интервалы (неравенства), упрощение выражений
(эквивалентность через численный сэмплинг по диапазону, а не строковое равенство).