feat(trainer): P10 — контент 8 класса (степени, формулы, неравенства)

- новый тип kind:inequality: answerRel{op,bound}, парсер отношения (_parseRel/_checkInequality) — нормализация «x op c», приём обратной записи, сверка op+границы; self-check внутри/снаружи решения
- темы: Степени (aⁿ, xᵃ·xᵇ, (xᵃ)ᵇ), Формулы сокр. умножения (квадрат суммы/разности, разность квадратов), Неравенства (вкл. смену знака при делении на отрицательное) → 26 генераторов, 8 тем
- движок: simplify рендерит выражение в KaTeX (exprToLatex(srcExpr)); неравенство — в KaTeX с отношением; fallback-display учитывает op
- страница: ввод/лейбл для неравенств, isLabelKind
- смоук движка 397/397 (T15 неравенства, T16 степени/формулы; T3 ≥10 для малых пространств), страница 33/33; ROADMAP_V2 P10 → DONE

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-25 15:20:45 +03:00
parent 277bddf1fd
commit 47d4f71eac
4 changed files with 212 additions and 15 deletions
+59 -9
View File
@@ -276,12 +276,19 @@
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 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);
}
var problem = {
genId: gen.id,
@@ -290,12 +297,13 @@
kind: kind,
lhsExpr: lhsExpr,
rhsExpr: rhsExpr,
display: prettyMath(render(gen.display || (gen.lhs + ' = ' + gen.rhs), env)),
latex: (ll != null && rl != null) ? (ll + ' = ' + rl) : null,
display: 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)
answerVars: gen.answerVars || [answerVar],
params: env,
// шаг решения -> { note(текст), tex(подпись), latex(для KaTeX, null если не разобрался) }
@@ -316,6 +324,13 @@
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 (answers) {
okSelf = answers.every(function (r) { return verifyRoot(problem, r).ok; });
why = 'не все корни удовлетворяют уравнению';
@@ -357,8 +372,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);
if (problem.kind === 'simplify') return _checkEquiv(problem, raw);
if (problem.kind === 'roots') return _checkMultiRoot(problem, raw);
if (problem.kind === 'inequality') return _checkInequality(problem, raw);
var c = SE().compile(raw);
if (c.error) {
@@ -413,6 +429,40 @@
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 равносильно исходному ⟺ выполняется во ВСЕХ корнях
+131 -1
View File
@@ -25,7 +25,10 @@
{ key: 'proportions', label: 'Пропорции', subject: 'algebra', grade: 7, order: 2 },
{ 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 }
{ key: 'quadratic', label: 'Квадратные', subject: 'algebra', grade: 8, order: 5 },
{ key: 'powers', label: 'Степени', subject: 'algebra', grade: 8, order: 6 },
{ key: 'formulas', label: 'Формулы', subject: 'algebra', grade: 8, order: 7 },
{ key: 'inequalities', label: 'Неравенства', subject: 'algebra', grade: 8, order: 8 }
];
var GENERATORS = [
@@ -312,6 +315,133 @@
{ note: 'Первый корень:', tex: 'x = {a}' },
{ note: 'Второй корень:', tex: 'x = -{a}' }
]
},
/* ═══ Тема: Степени ═══ */
/* вычислить aⁿ */
{
id: 'pow-eval', topic: 'powers', order: 1, subject: 'algebra', grade: 8, kind: 'compute',
title: 'Вычислить степень',
pick: { a: [2, 6], n: [2, 3] },
derive: { val: 'a^n' },
lhs: 'x', rhs: '{a}^{n}', display: 'Вычислите {a} в степени {n}',
answerVar: 'x', answer: 'val', integerAnswer: true,
solution: [
{ note: 'Степень — это повторное умножение основания на себя.', tex: 'x = {a}^{n}' },
{ note: 'Считаем:', tex: 'x = {ans}' }
]
},
/* xᵃ · xᵇ = xᵃ⁺ᵇ */
{
id: 'pow-mult', topic: 'powers', order: 2, subject: 'algebra', grade: 8, kind: 'simplify',
title: 'Произведение степеней',
pick: { a: [2, 5], b: [2, 5] },
derive: { s: 'a + b' },
srcExpr: 'x^{a}*x^{b}', answerExpr: 'x^{s}', answerVars: ['x'],
display: 'Упростите: x^{a}·x^{b}',
solution: [
{ note: 'При умножении степеней с одинаковым основанием показатели складываются: {a} + {b} = {s}.', tex: 'x^{a}*x^{b} = x^{s}' }
]
},
/* (xᵃ)ᵇ = xᵃᵇ */
{
id: 'pow-pow', topic: 'powers', order: 3, subject: 'algebra', grade: 8, kind: 'simplify',
title: 'Степень степени',
pick: { a: [2, 4], b: [2, 3] },
derive: { ab: 'a*b' },
srcExpr: '(x^{a})^{b}', answerExpr: 'x^{ab}', answerVars: ['x'],
display: 'Упростите: (x^{a})^{b}',
solution: [
{ note: 'При возведении степени в степень показатели перемножаются: {a}·{b} = {ab}.', tex: '(x^{a})^{b} = x^{ab}' }
]
},
/* ═══ Тема: Формулы сокращённого умножения ═══ */
/* (x + a)² */
{
id: 'sq-sum', topic: 'formulas', order: 1, subject: 'algebra', grade: 8, kind: 'simplify',
title: 'Квадрат суммы',
pick: { a: [1, 9] },
derive: { a2: 'a*a', a2x: '2*a' },
srcExpr: '(x + {a})^2', answerExpr: 'x^2 + {a2x}*x + {a2}', answerVars: ['x'],
display: 'Раскройте: (x + {a})²',
solution: [
{ note: 'Квадрат суммы: (x + {a})² = x² + 2·{a}·x + {a}².', tex: '(x + {a})^2 = x^2 + {a2x}*x + {a2}' }
]
},
/* (x a)² */
{
id: 'sq-diff', topic: 'formulas', order: 2, subject: 'algebra', grade: 8, kind: 'simplify',
title: 'Квадрат разности',
pick: { a: [1, 9] },
derive: { a2: 'a*a', a2x: '2*a' },
srcExpr: '(x - {a})^2', answerExpr: 'x^2 - {a2x}*x + {a2}', answerVars: ['x'],
display: 'Раскройте: (x {a})²',
solution: [
{ note: 'Квадрат разности: (x − {a})² = x² 2·{a}·x + {a}².', tex: '(x - {a})^2 = x^2 - {a2x}*x + {a2}' }
]
},
/* (x a)(x + a) = x² a² */
{
id: 'diff-sq', topic: 'formulas', order: 3, subject: 'algebra', grade: 8, kind: 'simplify',
title: 'Разность квадратов',
pick: { a: [2, 9] },
derive: { a2: 'a*a' },
srcExpr: '(x - {a})*(x + {a})', answerExpr: 'x^2 - {a2}', answerVars: ['x'],
display: 'Раскройте: (x {a})(x + {a})',
solution: [
{ note: 'Произведение разности и суммы даёт разность квадратов: (x − {a})(x + {a}) = x² {a}².', tex: '(x - {a})*(x + {a}) = x^2 - {a2}' }
]
},
/* ═══ Тема: Линейные неравенства ═══ */
/* ax + b < c (a>0, знак сохраняется) */
{
id: 'ineq-lt', topic: 'inequalities', order: 1, subject: 'algebra', grade: 8, kind: 'inequality',
title: 'ax + b < c',
pick: { a: [2, 6], b: [1, 15], root: [-8, 8] },
derive: { c: 'a*root + b', cmb: 'a*root' },
lhs: '{a}*x + {b}', rhs: '{c}', dispOp: '<', relOp: '<', bound: 'root',
answerVar: 'x',
solution: [
{ note: 'Переносим свободный член {b} вправо:', tex: '{a}x < {cmb}' },
{ note: 'Делим обе части на {a} — число положительное, знак неравенства не меняется:', tex: 'x < {root}' }
]
},
/* ax + b ≥ c (a>0) */
{
id: 'ineq-ge', topic: 'inequalities', order: 2, subject: 'algebra', grade: 8, kind: 'inequality',
title: 'ax + b ≥ c',
pick: { a: [2, 6], b: [1, 15], root: [-8, 8] },
derive: { c: 'a*root + b', cmb: 'a*root' },
lhs: '{a}*x + {b}', rhs: '{c}', dispOp: '>=', relOp: '>=', bound: 'root',
answerVar: 'x',
solution: [
{ note: 'Переносим {b} вправо:', tex: '{a}x >= {cmb}' },
{ note: 'Делим на {a} (положительное) — знак сохраняется:', tex: 'x >= {root}' }
]
},
/* ax + b < c (коэффициент отрицательный → знак МЕНЯЕТСЯ) */
{
id: 'ineq-flip', topic: 'inequalities', order: 3, subject: 'algebra', grade: 8, kind: 'inequality',
title: 'ax + b < c (смена знака)',
pick: { a: [2, 6], b: [1, 15], root: [-8, 8] },
derive: { c: 'b - a*root', cmb: '-a*root' },
lhs: '-{a}*x + {b}', rhs: '{c}', dispOp: '<', relOp: '>', bound: 'root',
answerVar: 'x',
solution: [
{ note: 'Переносим {b} вправо:', tex: '-{a}x < {cmb}' },
{ note: 'Делим на отрицательное число (−{a}) — знак неравенства МЕНЯЕТСЯ на противоположный:', tex: 'x > {root}' }
]
}
];
+10 -4
View File
@@ -643,17 +643,23 @@
// Префикс «x =» и подсказка ввода зависят от типа задачи.
function applyInputMode() {
var k = cur && cur.kind;
var multi = (k === 'roots' || k === 'simplify');
var multi = (k === 'roots' || k === 'simplify' || k === 'inequality');
var eqx = $('tr-eqx'); if (eqx) eqx.style.display = multi ? 'none' : '';
$('tr-input').placeholder = (k === 'roots') ? 'корни через ;' : (k === 'simplify') ? 'упрощённое выражение' : 'ответ';
$('tr-input').placeholder = (k === 'roots') ? 'корни через ;'
: (k === 'simplify') ? 'упрощённое выражение'
: (k === 'inequality') ? ('напр. ' + (cur.answerVar || 'x') + ' < 3')
: 'ответ';
var tog = $('tr-step-toggle'); if (tog) tog.style.display = canStep() ? '' : 'none';
}
// Текст ответа в фидбеке/раскрытии — по типу задачи.
var REL_SYM = { '<': '<', '>': '>', '<=': '≤', '>=': '≥' };
function answerLabel() {
if (cur.kind === 'roots' && cur.answers) return 'Корни: ' + cur.answers.map(fmt).join('; ');
if (cur.kind === 'simplify') return '= ' + (cur.answerExpr ? fmt(cur.answerExpr) : '');
if (cur.kind === 'inequality' && cur.answerRel) return (cur.answerVar || 'x') + ' ' + (REL_SYM[cur.answerRel.op] || cur.answerRel.op) + ' ' + fmt(cur.answerRel.bound);
return 'x = ' + fmt(cur.answer);
}
function isLabelKind() { return cur.kind === 'roots' || cur.kind === 'simplify' || cur.kind === 'inequality'; }
function updateStats() { $('tr-solved').textContent = solved; $('tr-streak').textContent = streak; }
function stepHtml(st, n) {
@@ -764,7 +770,7 @@
$('tr-input').disabled = true;
var si = $('tr-stepin'); if (si) si.disabled = true;
var fb = $('tr-feedback'); fb.className = 'tr-feedback';
if (cur.kind === 'roots' || cur.kind === 'simplify') fb.textContent = 'Ответ: ' + answerLabel();
if (isLabelKind()) fb.textContent = 'Ответ: ' + answerLabel();
else setMath(fb, 'x = ' + cur.answer, 'Ответ: x = ' + fmt(cur.answer), false);
setMode(true);
recordAnswer(false); submitAttempt(false);
@@ -783,7 +789,7 @@
setMode(true);
if (r.ok) {
fb.className = 'tr-feedback ok';
var lbl = (cur.kind === 'roots' || cur.kind === 'simplify') ? esc(answerLabel())
var lbl = isLabelKind() ? esc(answerLabel())
: (kat('x = ' + cur.answer, false) || esc('x = ' + fmt(cur.answer)));
fb.innerHTML = ICON.ok + ' <span>Верно!</span>&nbsp;' + lbl;
$('tr-input').disabled = true;
+12 -1
View File
@@ -50,7 +50,18 @@ solved-форме `x=c` → общий `onSolved` (засчитывается к
(`callLLMFailover`) — только ОБЪЯСНЕНИЯ (безопасно, не генерация задач).
- 3 уровня подсказок (намёк → шаг → решение).
## P10 — Контент 5–9 классов + ЦТ
## P10 — Контент 5–9 классов + ЦТ — DONE (частично)
**Сделано:** +3 темы (8 всего, 26 генераторов): **Степени** (вычислить aⁿ; xᵃ·xᵇ; (xᵃ)ᵇ),
**Формулы сокр. умножения** (квадрат суммы/разности, разность квадратов), **Линейные
неравенства** — НОВЫЙ тип `kind:'inequality'` (`answerRel:{op,bound}`; парсер отношения
`_parseRel`/`_checkInequality` — нормализация «x op c», приём обратной записи «c op x»,
сверка op+границы; ineq-flip учит смене знака при делении на отрицательное). Движок:
**simplify теперь рендерит выражение в KaTeX** (`latex = exprToLatex(srcExpr)`, eyebrow =
действие), неравенство — в KaTeX с отношением; self-check неравенства (внутри/снаружи
решения). Страница: ввод/лейбл для неравенств (`x < 3`), `isLabelKind`. Смоук 397/397
(T15 неравенства, T16 степени/формулы). **Осталось (стретч):** системы 2 ур-ний
(пара-ответ), дроби 5–6, явная привязка к таксономии ЦТ.
Расширить охват и связать с подготовкой к ЦТ/ЦЭ.
- Новые темы: арифметика/дроби/десятичные (5–6), степени, формулы сокр. умножения,
разложение на множители, **линейные неравенства** (новый тип ответа: парсинг и