d07cb2a434
- движок: instantiate(gen,{level}) масштабирует диапазоны pick (_scaleRange): L2=база, L1 меньше магнитуды/меньше отрицательных, L3 шире → сложнее; универсально для всех генераторов (корень-вперёд + самопроверка держат корректность), opt-out gen.noScale; generateBatch прокидывает level
- страница: контрол «Сложность: Авто / Лёгкий / Средний / Сложный» в рабочей зоне; «Авто» поднимает уровень с серией верных (streak≥2→2, ≥4→3, ошибка→1); скрыт для текстовых задач из банка
- смоук движка 682/682 (T18: 36 ген × L1/L2/L3, L3 шире L1, L2==база), страница 34/34; эмодзи/eval 0
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
558 lines
30 KiB
JavaScript
558 lines
30 KiB
JavaScript
'use strict';
|
||
/* ════════════════════════════════════════════════════════════════════════
|
||
TrainerEngine — ядро ИИ-тренажёра (Фаза 0, прототип).
|
||
|
||
Идея (гибрид): задачи рождаются из ДАННЫХ — «генераторов», а математика
|
||
считается ДЕТЕРМИНИРОВАННО через SimExpr (тот же безопасный вычислитель, что
|
||
у конструктора симуляций; ⛔ без eval/new Function). LLM в этом ядре НЕ
|
||
участвует: его роль — один раз сочинить генераторы (Уровень 0) либо позже
|
||
отдавать текстовые задачи, которые ЭТОТ ЖЕ слой верифицирует подстановкой
|
||
(Уровень 1). Любой источник задачи проходит один и тот же verifyRoot.
|
||
|
||
Генератор (данные):
|
||
{
|
||
id, skill, title,
|
||
pick: { a:[lo,hi], ... }, // целые параметры из диапазонов
|
||
constraint?: "c < a", // булево над pick (SimExpr) — иначе пересэмпл
|
||
derive?: { c: "a*root + b" }, // доп. параметры последовательно (SimExpr)
|
||
require?: "...", // булево после derive — иначе пересэмпл
|
||
lhs, rhs, // СТОРОНЫ уравнения как выражения с {param} и x
|
||
display?, // как показать (по умолч. "lhs = rhs")
|
||
answerVar?: "x", // имя неизвестной (деф. x)
|
||
answer: "root", // корень как формула над параметрами
|
||
integerAnswer?: true, // требовать целый корень
|
||
solution?: ["шаг … {ans}", …] // шаблоны шагов (доступен {ans})
|
||
}
|
||
|
||
Гарантия КОРРЕКТНОСТИ: после материализации движок ПОДСТАВЛЯЕТ заявленный
|
||
корень в уравнение (verifyRoot). Не сходится — экземпляр отбрасывается (в
|
||
strict-режиме — исключение). Та же подстановка проверяет ответ ученика
|
||
(checkStudentAnswer) и автоматически принимает эквивалентные формы
|
||
(5, 5.0, 10/2, "x=15/3", "2+3").
|
||
|
||
API (window.TrainerEngine):
|
||
instantiate(gen, opts) -> problem | null
|
||
generateBatch(gen, n, opts) -> problem[]
|
||
verifyRoot(problem, value) -> { ok, residual, lhs, rhs }
|
||
checkStudentAnswer(problem, input)-> { ok, value, residual, message, reason? }
|
||
makeRng(seed) -> () => [0,1) (детерминизм для тестов/пула)
|
||
|
||
problem:
|
||
{ genId, skill, title, lhsExpr, rhsExpr, display, answerVar, answer,
|
||
params, solution }
|
||
════════════════════════════════════════════════════════════════════════ */
|
||
(function (global) {
|
||
|
||
function SE() {
|
||
var s = global.SimExpr;
|
||
if (!s) throw new Error('TrainerEngine требует SimExpr (подключите _sim_expr.js раньше).');
|
||
return s;
|
||
}
|
||
|
||
// Допуск подстановки: масштабируется величиной сторон, чтобы крупные
|
||
// коэффициенты не давали ложного «не сходится» из-за плавающей арифметики.
|
||
var EPS = 1e-7;
|
||
|
||
/* ── Детерминированный ГПСЧ (mulberry32) — тот же, что в game/map.js ──
|
||
Нужен, чтобы предгенерация пула и тесты были воспроизводимы. В рантайме
|
||
можно не передавать seed (тогда берётся внутренний инкремент от Date нельзя —
|
||
поэтому дефолт фиксирован, а вариативность даёт сам диапазон pick). */
|
||
function makeRng(seed) {
|
||
var s = (seed >>> 0) || 1;
|
||
return function () {
|
||
s |= 0; s = (s + 0x6D2B79F5) | 0;
|
||
var t = Math.imul(s ^ (s >>> 15), 1 | s);
|
||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||
};
|
||
}
|
||
function randInt(rng, lo, hi) { return lo + Math.floor(rng() * (hi - lo + 1)); }
|
||
|
||
/* ── Уровни сложности: масштабирование диапазона pick ──
|
||
level 2 — базовый (как задано); 1 — легче (меньше магнитуды, меньше
|
||
отрицательных); 3 — сложнее (шире магнитуды). Универсально для всех
|
||
генераторов; корректность держит «корень-вперёд» + самопроверка. */
|
||
function _scaleRange(r, level) {
|
||
var lo = r[0], hi = r[1];
|
||
if (!level || level === 2) return [lo, hi];
|
||
if (level === 1) {
|
||
var nlo = lo < 0 ? Math.ceil(lo / 2) : lo;
|
||
var nhi = hi > 0 ? Math.max(nlo + 1, Math.round(hi / 2)) : hi;
|
||
return [nlo, nhi];
|
||
}
|
||
var elo = lo < 0 ? Math.floor(lo * 1.8) : lo;
|
||
var ehi = Math.round(hi * 1.8);
|
||
if (ehi <= elo) ehi = elo + 1;
|
||
return [elo, ehi];
|
||
}
|
||
|
||
/* ── Кэш компиляции выражений (рендеренные строки часто повторяются) ── */
|
||
var _cache = Object.create(null);
|
||
function compileExpr(src) {
|
||
var key = String(src);
|
||
var c = _cache[key];
|
||
if (!c) { c = SE().compile(key); _cache[key] = c; }
|
||
return c;
|
||
}
|
||
function evalExpr(src, env) { return compileExpr(src).fn(env); }
|
||
|
||
function truthy(v) { return typeof v === 'number' && isFinite(v) && v !== 0; }
|
||
function isIntApprox(v) { return isFinite(v) && Math.abs(v - Math.round(v)) < 1e-9; }
|
||
|
||
function fmtNum(v) {
|
||
if (typeof v !== 'number') return String(v);
|
||
if (isIntApprox(v)) return String(Math.round(v));
|
||
return String(Math.round(v * 1e6) / 1e6);
|
||
}
|
||
|
||
/* Подстановка {name} -> значение (для выражений и подписей). */
|
||
function render(tpl, vals) {
|
||
return String(tpl).replace(/\{(\w+)\}/g, function (m, k) {
|
||
return Object.prototype.hasOwnProperty.call(vals, k) ? fmtNum(vals[k]) : m;
|
||
});
|
||
}
|
||
|
||
/* Лёгкая косметика ТОЛЬКО для показа (не для вычислений):
|
||
5*x -> 5x, «+ -» -> «− », ведущий коэффициент 1 у x убираем. */
|
||
function prettyMath(s) {
|
||
return String(s)
|
||
.replace(/(\d)\s*\*\s*(\d)/g, '$1·$2') // 4*5 -> 4·5 (число·число)
|
||
.replace(/\s*\*\s*/g, '') // 7*x -> 7x (неявное умножение)
|
||
.replace(/\+\s*-\s*/g, '− ') // + -3 -> − 3
|
||
.replace(/-\s*-\s*/g, '+ ')
|
||
.replace(/(^|[(=+\-\s])1(?=x)/g, '$1'); // ведущий 1·x -> x
|
||
}
|
||
|
||
function assign(base, extra) {
|
||
var o = {}, k;
|
||
for (k in base) if (Object.prototype.hasOwnProperty.call(base, k)) o[k] = base[k];
|
||
for (k in extra) if (Object.prototype.hasOwnProperty.call(extra, k)) o[k] = extra[k];
|
||
return o;
|
||
}
|
||
|
||
/* ── Выражение -> LaTeX (через AST SimExpr) для KaTeX-рендера ──
|
||
Возвращает строку LaTeX или null, если выражение не разобралось. Покрывает
|
||
наши нужды: дроби (\frac), степени, неявное умножение, скобки по приоритету,
|
||
сравнения (= ≠ ≤ ≥), sqrt/abs/тригонометрию. Один проход AST, без eval.
|
||
Reusable: тем же конвертером можно рендерить и задачи Уровня-1 (LLM). */
|
||
function _prec(n) {
|
||
if (!n) return 9;
|
||
if (n.k === 'cmp' || n.k === 'logic') return 0;
|
||
if (n.k === 'bin') {
|
||
if (n.op === '+' || n.op === '-') return 1;
|
||
if (n.op === '*' || n.op === '/' || n.op === '%') return 2;
|
||
if (n.op === '^') return 4;
|
||
}
|
||
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 === '-') ||
|
||
(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;
|
||
}
|
||
// Операнд умножения: отрицательное/унарное/сумму берём в скобки, иначе
|
||
// соседство схлопнет смысл (7*(-5) -> «7-5», 6*(x+1) -> «6x+1»).
|
||
function _mulOperand(node) {
|
||
if (_isNeg(node) || _prec(node) < 2) return '\\left(' + _latex(node) + '\\right)';
|
||
return _latex(node);
|
||
}
|
||
function _latex(node) {
|
||
switch (node.k) {
|
||
case 'num': return fmtNum(node.v);
|
||
case 'const':
|
||
if (node.v === Math.PI) return '\\pi';
|
||
if (node.v === Math.PI * 2) return '\\tau';
|
||
if (node.v === Math.E) return 'e';
|
||
return fmtNum(node.v);
|
||
case 'var': return node.name;
|
||
case 'un': return '-' + _wrapL(node.a, 3);
|
||
case 'not': return '\\lnot ' + _wrapL(node.a, 3);
|
||
case 'cmp': {
|
||
var m = { '==': '=', '!=': '\\ne', '<': '<', '<=': '\\le', '>': '>', '>=': '\\ge' };
|
||
return _latex(node.a) + ' ' + (m[node.op] || node.op) + ' ' + _latex(node.b);
|
||
}
|
||
case 'logic':
|
||
return _latex(node.a) + (node.op === '&&' ? ' \\land ' : ' \\lor ') + _latex(node.b);
|
||
case 'cond':
|
||
return _wrapL(node.c, 1) + ' \\,?\\, ' + _latex(node.a) + ' : ' + _latex(node.b);
|
||
case 'call': {
|
||
if (node.name === 'sqrt') return '\\sqrt{' + _latex(node.args[0]) + '}';
|
||
if (node.name === 'abs') return '\\left|' + _latex(node.args[0]) + '\\right|';
|
||
var TRIG = { sin: '\\sin', cos: '\\cos', tan: '\\tan', tg: '\\tan', ln: '\\ln', log: '\\log', exp: '\\exp' };
|
||
var fn = TRIG[node.name] || ('\\operatorname{' + node.name + '}');
|
||
return fn + '\\left(' + node.args.map(_latex).join(',\\, ') + '\\right)';
|
||
}
|
||
case 'bin': {
|
||
var op = node.op;
|
||
if (op === '/') return '\\frac{' + _latex(node.a) + '}{' + _latex(node.b) + '}';
|
||
if (op === '^') {
|
||
var base = _prec(node.a) < 5 ? '\\left(' + _latex(node.a) + '\\right)' : _latex(node.a);
|
||
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)
|
||
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);
|
||
}
|
||
}
|
||
return '';
|
||
}
|
||
function exprToLatex(src) {
|
||
var ast;
|
||
try { ast = SE().parse(String(src)); } catch (e) { return null; }
|
||
try { return _latex(ast); } catch (e2) { return null; }
|
||
}
|
||
|
||
/* ── Подстановочная верификация корня ──
|
||
Истинно, если левая и правая части совпадают при answerVar = value. */
|
||
function verifyRoot(problem, value) {
|
||
var env = {};
|
||
env[problem.answerVar || 'x'] = value;
|
||
var L = evalExpr(problem.lhsExpr, env);
|
||
var R = evalExpr(problem.rhsExpr, env);
|
||
var residual = Math.abs(L - R);
|
||
var scale = Math.max(1, Math.abs(L), Math.abs(R));
|
||
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 не удалось выполнить
|
||
ограничения / целочисленность / самопроверку. */
|
||
function instantiate(gen, opts) {
|
||
opts = opts || {};
|
||
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
|
||
var maxTries = opts.maxTries || 300;
|
||
var answerVar = gen.answerVar || 'x';
|
||
|
||
for (var attempt = 0; attempt < maxTries; attempt++) {
|
||
var env = {};
|
||
var pk = gen.pick || {}, k;
|
||
var lvl = opts.level;
|
||
for (k in pk) if (Object.prototype.hasOwnProperty.call(pk, k)) {
|
||
var rk = (lvl && !gen.noScale) ? _scaleRange(pk[k], lvl) : pk[k];
|
||
env[k] = randInt(rng, rk[0], rk[1]);
|
||
}
|
||
|
||
if (gen.constraint && !truthy(evalExpr(gen.constraint, env))) continue;
|
||
|
||
if (gen.derive) {
|
||
for (k in gen.derive) if (Object.prototype.hasOwnProperty.call(gen.derive, k)) {
|
||
env[k] = evalExpr(gen.derive[k], env);
|
||
}
|
||
}
|
||
|
||
if (gen.require && !truthy(evalExpr(gen.require, env))) continue;
|
||
|
||
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 || 'x', env);
|
||
var rhsExpr = render(gen.rhs || 'x', env);
|
||
var sEnv = assign(env, { ans: answer });
|
||
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,
|
||
skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан
|
||
title: gen.title,
|
||
kind: kind,
|
||
lhsExpr: lhsExpr,
|
||
rhsExpr: rhsExpr,
|
||
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 если не разобрался) }
|
||
// строковый шаг (легаси) трактуется как чистая заметка без формулы.
|
||
solution: (gen.solution || []).map(function (st) {
|
||
if (typeof st === 'string') return { note: render(st, sEnv), tex: '', latex: null };
|
||
var texSrc = st.tex ? render(st.tex, sEnv) : '';
|
||
return {
|
||
note: st.note ? render(st.note, sEnv) : '',
|
||
tex: texSrc ? prettyMath(texSrc) : '',
|
||
latex: texSrc ? exprToLatex(texSrc) : null
|
||
};
|
||
})
|
||
};
|
||
|
||
// Самопроверка по типу: simplify → эквивалентность; roots → все корни; иначе → корень.
|
||
var okSelf, why;
|
||
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 = 'не все корни удовлетворяют уравнению';
|
||
} 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;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/* ── Пакет из n различных по виду задач ── */
|
||
function generateBatch(gen, n, opts) {
|
||
opts = opts || {};
|
||
var rng = opts.rng || makeRng(opts.seed != null ? opts.seed : 1);
|
||
var out = [], seen = Object.create(null);
|
||
var guard = n * 20 + 50;
|
||
while (out.length < n && guard-- > 0) {
|
||
var p = instantiate(gen, { rng: rng, strict: opts.strict, maxTries: opts.maxTries, level: opts.level });
|
||
if (!p) break;
|
||
if (seen[p.display]) continue;
|
||
seen[p.display] = 1;
|
||
out.push(p);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/* ── Проверка ответа ученика ──
|
||
Принимает строку/число. SimExpr.compile сам срезает ведущее «x=», поэтому
|
||
"x = 5", "5", "10/2", "2+3" нормализуются к числу. Верно, если значение
|
||
удовлетворяет уравнению (эквивалентные формы проходят) ИЛИ совпадает с
|
||
эталонным корнем (страховка единственности для будущих многокорневых типов). */
|
||
function checkStudentAnswer(problem, input) {
|
||
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 === 'inequality') return _checkInequality(problem, raw);
|
||
|
||
var c = SE().compile(raw);
|
||
if (c.error) {
|
||
return { ok: false, reason: 'parse', value: null, residual: null,
|
||
message: 'Не понял ответ: ' + c.error };
|
||
}
|
||
var val = c.fn({});
|
||
if (!isFinite(val)) {
|
||
return { ok: false, reason: 'nan', value: val, residual: null, message: 'Это не число.' };
|
||
}
|
||
|
||
var v = verifyRoot(problem, val);
|
||
var nearCanonical = Math.abs(val - problem.answer) <= 1e-6 * Math.max(1, Math.abs(problem.answer));
|
||
var ok = v.ok || nearCanonical;
|
||
return {
|
||
ok: ok, reason: ok ? null : 'wrong', value: val, residual: v.residual,
|
||
message: ok ? 'Верно!' : 'Пока неверно.'
|
||
};
|
||
}
|
||
|
||
/* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */
|
||
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 ? 'Верно!' : 'Пока неверно.' };
|
||
}
|
||
|
||
/* ── Неравенства: проверка ответа-отношения «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 равносильно исходному ⟺ выполняется во ВСЕХ корнях
|
||
и НЕ выполняется в точках-не-корнях (то есть сужает x именно до корней).
|
||
Ловит арифметику (не держится в корне), потерю корня и тождество «0=0». */
|
||
function _splitEq(s) {
|
||
var i = String(s).indexOf('=');
|
||
if (i <= 0 || i >= s.length - 1) return null;
|
||
if (s.indexOf('=', i + 1) !== -1) return null; // нет цепочек a=b=c и составных ==,<=,>=
|
||
return [s.slice(0, i).trim(), s.slice(i + 1).trim()];
|
||
}
|
||
function _isConst(c, v) {
|
||
var e1 = {}, e2 = {}; e1[v] = 1.3; e2[v] = 2.7;
|
||
return Math.abs(c.fn(e1) - c.fn(e2)) < 1e-9;
|
||
}
|
||
function _isVarOnly(s, v) { return String(s).replace(/\s+/g, '') === v; }
|
||
function _isSolvedForm(lhs, rhs, v, roots) {
|
||
var cl = SE().compile(lhs), cr = SE().compile(rhs);
|
||
if (cl.error || cr.error) return false;
|
||
var lv = _isVarOnly(lhs, v), rv = _isVarOnly(rhs, v);
|
||
if (lv && _isConst(cr, v)) { var a = cr.fn({}); return roots.some(function (r) { return Math.abs(a - r) <= 1e-6; }); }
|
||
if (rv && _isConst(cl, v)) { var b = cl.fn({}); return roots.some(function (r) { return Math.abs(b - r) <= 1e-6; }); }
|
||
return false;
|
||
}
|
||
function checkStep(problem, line) {
|
||
var raw = String(line == null ? '' : line).trim();
|
||
if (!raw) return { ok: false, status: 'empty', message: 'Введите шаг — равенство со знаком «=».' };
|
||
var parts = _splitEq(raw);
|
||
if (!parts) return { ok: false, status: 'parse', message: 'Шаг — это одно равенство со знаком «=».' };
|
||
var cl = SE().compile(parts[0]), cr = SE().compile(parts[1]);
|
||
if (cl.error || cr.error) return { ok: false, status: 'parse', message: 'Не понял выражение в шаге.' };
|
||
|
||
var v = problem.answerVar || 'x';
|
||
var roots = (problem.answers && problem.answers.length) ? problem.answers : [problem.answer];
|
||
|
||
// держится во всех корнях?
|
||
for (var i = 0; i < roots.length; i++) {
|
||
var env = {}; env[v] = roots[i];
|
||
var L = cl.fn(env), R = cr.fn(env);
|
||
if (Math.abs(L - R) > 1e-7 * Math.max(1, Math.abs(L), Math.abs(R)))
|
||
return { ok: false, status: 'wrong', message: 'Не равносильно: при ' + v + ' = ' + fmtNum(roots[i]) + ' равенство не выполняется.' };
|
||
}
|
||
// сужает x до корней? (в не-корнях должно НЕ выполняться)
|
||
var total = 0, holds = 0;
|
||
for (var j = 0; j < _EQUIV_PTS.length; j++) {
|
||
var x = _EQUIV_PTS[j];
|
||
if (roots.some(function (r) { return Math.abs(x - r) < 1e-6; })) continue;
|
||
total++; var e2 = {}; e2[v] = x;
|
||
var L2 = cl.fn(e2), R2 = cr.fn(e2);
|
||
if (Math.abs(L2 - R2) <= 1e-7 * Math.max(1, Math.abs(L2), Math.abs(R2))) holds++;
|
||
}
|
||
if (total > 0 && holds === total)
|
||
return { ok: false, status: 'identity', message: 'Это тождество — верно при любом ' + v + ' и не приближает к ответу.' };
|
||
|
||
var done = _isSolvedForm(parts[0], parts[1], v, roots);
|
||
return { ok: true, status: done ? 'solved' : 'equivalent', message: done ? 'Готово!' : 'Верный шаг.' };
|
||
}
|
||
|
||
global.TrainerEngine = {
|
||
instantiate: instantiate,
|
||
generateBatch: generateBatch,
|
||
verifyRoot: verifyRoot,
|
||
checkStudentAnswer: checkStudentAnswer,
|
||
checkStep: checkStep,
|
||
makeRng: makeRng,
|
||
// мелочи наружу для билдера/тестов
|
||
render: render,
|
||
prettyMath: prettyMath,
|
||
exprToLatex: exprToLatex
|
||
};
|
||
|
||
})(typeof window !== 'undefined' ? window : globalThis);
|