'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 === '*') { // единичный коэффициент: 1*x -> x, (-1)*x -> -x (только при не-числовом множителе) if (node.a.k === 'num' && Math.abs(node.a.v) === 1 && node.b.k !== 'num') return (node.a.v < 0 ? '-' : '') + _mulOperand(node.b); if (node.b.k === 'num' && Math.abs(node.b.v) === 1 && node.a.k !== 'num') return (node.b.v < 0 ? '-' : '') + _mulOperand(node.a); 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); } // система уравнений (kind system): набор строк + пара-ответ {x,y,...} var system = null, pair = null; if (kind === 'system') { system = (gen.eqs || []).map(function (e) { return { lhs: render(e.lhs, env), rhs: render(e.rhs, env) }; }); pair = {}; var avs = gen.answerVars || ['x', 'y']; for (var ai = 0; ai < avs.length; ai++) { var pv = evalExpr((gen.answers && gen.answers[avs[ai]]) || '0', env); pair[avs[ai]] = gen.integerAnswer ? Math.round(pv) : pv; } answer = pair[avs[0]]; // запасной одиночный ответ } 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); } else if (kind === 'system' && system) { var rows = [], okrows = true; for (var si2 = 0; si2 < system.length; si2++) { var l2 = exprToLatex(system[si2].lhs), r2 = exprToLatex(system[si2].rhs); if (l2 == null || r2 == null) { okrows = false; break; } rows.push(l2 + ' = ' + r2); } if (okrows) latex = '\\begin{cases} ' + rows.join(' \\\\ ') + ' \\end{cases}'; } var problem = { genId: gen.id, skill: gen.skill || gen.id, // ключ прогресса = id генератора, если skill не задан title: gen.title, kind: kind, lhsExpr: lhsExpr, rhsExpr: rhsExpr, display: (kind === 'system' && system) ? system.map(function (e) { return prettyMath(e.lhs + ' = ' + e.rhs); }).join('; ') : 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) system: system, // [{lhs,rhs},…] (kind system) pair: pair, // эталонная пара {x,y,…} (kind system) 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 (kind === 'system') { okSelf = !!(system && system.length) && system.every(function (e) { var L = evalExpr(e.lhs, pair), R = evalExpr(e.rhs, pair); return Math.abs(L - R) <= EPS * Math.max(1, Math.abs(L), Math.abs(R)); }); 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); if (problem.kind === 'system') return _checkSystem(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 ? 'Верно!' : 'Пока неверно.' }; } /* ── Разбор типовой ошибки ученика (репетитор, направление C) ── По неверному ЧИСЛОВОМУ ответу пытается распознать типовую ошибку и дать адресную подсказку, НЕ выдавая правильный ответ. Работает для solve/compute. Для solve уравнение восстанавливается как линейное f(x)=A·x+B по двум точкам (без структуры генератора) → ловим «забыл разделить на коэффициент». Плюс общие эвристики: перепутан знак, близкая арифметическая ошибка. Возвращает { type, hint } или null (ошибка не распознана / ответ верный). */ function _linAB(problem) { var av = problem.answerVar || 'x'; var e0 = {}, e1 = {}; e0[av] = 0; e1[av] = 1; var g0 = evalExpr(problem.lhsExpr, e0) - evalExpr(problem.rhsExpr, e0); var g1 = evalExpr(problem.lhsExpr, e1) - evalExpr(problem.rhsExpr, e1); if (!isFinite(g0) || !isFinite(g1)) return null; return { A: g1 - g0, B: g0 }; // f(x) = A·x + B, корень = -B/A } function analyzeMistake(problem, value) { if (!problem || !isFinite(value)) return null; var kind = problem.kind || 'solve'; if (kind !== 'solve' && kind !== 'compute') return null; // пара/корни/неравенство — отдельно var correct = problem.answer; var tol = 1e-6 * Math.max(1, Math.abs(correct)); if (Math.abs(value - correct) <= tol) return null; // на самом деле верно // структурно: линейное уравнение → «забыл разделить на коэффициент» if (kind === 'solve') { var ab = _linAB(problem); if (ab && Math.abs(ab.A) > 1.5) { var noDivide = -ab.B; // значение на шаге «A·x = -B», ещё не делённое на A (= A·correct) if (Math.abs(value - noDivide) <= Math.max(tol, 1e-6 * Math.abs(noDivide))) return { type: 'nodivide', hint: 'Похоже, ты не разделил обе части на коэффициент при переменной (' + fmtNum(ab.A) + '). Раздели — и получишь ответ.' }; } } // перепутан знак ответа if (correct !== 0 && Math.abs(value + correct) <= Math.max(tol, 1e-6 * Math.abs(correct))) return { type: 'sign', hint: 'Кажется, перепутан знак. Проверь знаки при переносе слагаемых через знак «=».' }; // близкая арифметическая ошибка if (Math.abs(value - correct) <= Math.max(1, Math.abs(correct) * 0.2)) return { type: 'arith', hint: 'Очень близко — похоже на арифметическую ошибку в вычислениях. Пересчитай аккуратно.' }; return { type: 'generic', hint: 'Разбери решение по шагам и попробуй похожую задачу.' }; } /* Система: ученик вводит пару «x = 2; y = 3» (или «2; 3»). Проверяем подстановкой в ОБА уравнения. Метки переменных опциональны; без меток — по порядку answerVars. */ function _checkSystem(problem, raw) { var vars = problem.answerVars || ['x', 'y']; var parts = raw.split(/[;,]/).map(function (s) { return s.trim(); }).filter(Boolean); if (parts.length < vars.length) return { ok: false, reason: 'incomplete', message: 'Введите обе переменные, напр. x = 2; y = 3.' }; var vals = {}, pos = []; for (var i = 0; i < parts.length; i++) { var m = parts[i].match(/^([a-zA-Z]\w*)\s*=\s*(.+)$/); var c = SE().compile(m ? m[2] : parts[i]); if (c.error) return { ok: false, reason: 'parse', message: 'Не понял запись «' + parts[i] + '».' }; var num = c.fn({}); if (!isFinite(num)) return { ok: false, reason: 'nan', message: 'Это не число.' }; if (m) vals[m[1]] = num; else pos.push(num); } for (var j = 0; j < vars.length; j++) if (vals[vars[j]] === undefined && pos.length) vals[vars[j]] = pos.shift(); for (var j2 = 0; j2 < vars.length; j2++) if (vals[vars[j2]] === undefined) return { ok: false, reason: 'incomplete', message: 'Укажите ' + vars[j2] + '.' }; var sys = problem.system || []; for (var e = 0; e < sys.length; e++) { var L = evalExpr(sys[e].lhs, vals), R = evalExpr(sys[e].rhs, vals); if (Math.abs(L - R) > EPS * Math.max(1, Math.abs(L), Math.abs(R))) return { ok: false, reason: 'wrong', value: vals, message: 'Пара не подходит под уравнения системы.' }; } return { ok: true, reason: null, value: vals, message: 'Верно!' }; } /* Несколько корней: ученик вводит все через «;»/«,»/пробел; сверяем как мультимножество. */ 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, analyzeMistake: analyzeMistake, checkStep: checkStep, makeRng: makeRng, // мелочи наружу для билдера/тестов render: render, prettyMath: prettyMath, exprToLatex: exprToLatex }; })(typeof window !== 'undefined' ? window : globalThis);