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 равносильно исходному ⟺ выполняется во ВСЕХ корнях