From c063dd415d930e79d1a7c27f75677a2cf775970d Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:09:46 -0500 Subject: [PATCH 1/4] =?UTF-8?q?feat(math-engine):=20StepTrace=20layer=20?= =?UTF-8?q?=E2=80=94=20opt-in=20structured=20tracing=20for=20solver=20and?= =?UTF-8?q?=20limits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New trace module: TraceCollector (zero cost when off — every emission is trace?.emit behind a single undefined check), const-derived TraceRuleId union, DISPLAY_RULES whitelist, curateTrace (whitelist filter, no-op drop, consecutive-simplify merge) - solve(): optional trace option threaded through linear/quadratic/numerical paths, emitting coefficient/formula/root events with before/after ASTs and ICU-safe plain params (incl. Newton iteration counts) - limits(): LimitConfig.trace instrumentation across pattern/direct/ simplify/L'Hopital/series/numerical strategies; trace flows through the post-simplification recursion; legacy string steps untouched - step-solver: SolutionStep gains optional ruleId/params (i18n-addressable); every equation step tagged; new rational-equation path (domain restrictions, multiply-by-LCD, delegate to linear/quadratic/cubic, extraneous-root exclusion); StepCategory.Limit + limitWithSteps() mapping curated limit traces to textbook-shaped StepSolutions - ./trace subpath export; tests: trace curation, ruleId sequences for linear/quadratic(factor+formula)/rational/limits, ICU-safety guard, traced-vs-untraced parity Co-Authored-By: Claude Fable 5 --- packages/math-engine/package.json | 4 + packages/math-engine/src/index.ts | 3 + packages/math-engine/src/solver/solve.test.ts | 56 ++ packages/math-engine/src/solver/solve.ts | 100 ++- packages/math-engine/src/symbolic/limits.ts | 104 ++- .../src/symbolic/step-solver.test.ts | 199 +++++ .../math-engine/src/symbolic/step-solver.ts | 732 +++++++++++++++++- packages/math-engine/src/trace/index.ts | 1 + .../math-engine/src/trace/step-trace.test.ts | 210 +++++ packages/math-engine/src/trace/step-trace.ts | 208 +++++ 10 files changed, 1587 insertions(+), 30 deletions(-) create mode 100644 packages/math-engine/src/symbolic/step-solver.test.ts create mode 100644 packages/math-engine/src/trace/index.ts create mode 100644 packages/math-engine/src/trace/step-trace.test.ts create mode 100644 packages/math-engine/src/trace/step-trace.ts diff --git a/packages/math-engine/package.json b/packages/math-engine/package.json index 4200ae87..d7275fad 100644 --- a/packages/math-engine/package.json +++ b/packages/math-engine/package.json @@ -90,6 +90,10 @@ "./graph-theory": { "types": "./src/graph-theory/index.ts", "default": "./src/graph-theory/index.ts" + }, + "./trace": { + "types": "./src/trace/index.ts", + "default": "./src/trace/index.ts" } }, "scripts": { diff --git a/packages/math-engine/src/index.ts b/packages/math-engine/src/index.ts index 8303a657..bac346b8 100644 --- a/packages/math-engine/src/index.ts +++ b/packages/math-engine/src/index.ts @@ -316,6 +316,7 @@ export { integrateAdaptiveSimpson, integrateAuto, integrateImproper, + limitWithSteps, maclaurinSeries, quickSimplify, type SeriesConfig, @@ -327,3 +328,5 @@ export { // Series & LaTeX taylorSeries, } from './symbolic/index'; +// Step tracing (opt-in structured solver/limit traces) +export * from './trace/index'; diff --git a/packages/math-engine/src/solver/solve.test.ts b/packages/math-engine/src/solver/solve.test.ts index 87abd051..112e89d2 100644 --- a/packages/math-engine/src/solver/solve.test.ts +++ b/packages/math-engine/src/solver/solve.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest'; +import { TraceCollector } from '../trace/step-trace'; import { Complex, solve, solveInRange } from './solve'; describe('solve', () => { @@ -100,6 +101,61 @@ describe('solve', () => { }); }); + describe('step tracing (opt-in)', () => { + it('emits quadratic trace events with before/after ASTs', () => { + const trace = new TraceCollector(); + const solutions = solve('x^2 - 4', 'x', { trace }); + + const ids = trace.steps.map((s) => s.ruleId); + expect(ids).toContain('quadratic.coefficients'); + expect(ids).toContain('quadratic.formula'); + expect(ids).toContain('quadratic.evaluateRoots'); + + for (const step of trace.steps) { + expect(step.before._brand).toBe('ExpressionNode'); + expect(step.after._brand).toBe('ExpressionNode'); + } + + // The traced call solves identically + const values = solutions.map((s) => s.value as number).sort((a, b) => a - b); + expect(values[0]).toBeCloseTo(-2, 8); + expect(values[1]).toBeCloseTo(2, 8); + }); + + it('emits linear trace events including the division step', () => { + const trace = new TraceCollector(); + solve('2*x + 4', 'x', { trace }); + + const ids = trace.steps.map((s) => s.ruleId); + expect(ids).toContain('linear.coefficients'); + expect(ids).toContain('linear.divide'); + + const divide = trace.steps.find((s) => s.ruleId === 'linear.divide'); + expect(divide?.params['solution']).toBe('-2'); + }); + + it('emits the converged iteration count for numerical solving', () => { + const trace = new TraceCollector(); + solve('exp(x) - 2', 'x', { method: 'numerical', trace }); + + const ids = trace.steps.map((s) => s.ruleId); + expect(ids).toContain('numeric.newton'); + expect(ids).toContain('numeric.converged'); + + const converged = trace.steps.find((s) => s.ruleId === 'numeric.converged'); + expect(typeof converged?.params['iterations']).toBe('number'); + expect(Number(converged?.params['iterations'])).toBeGreaterThan(0); + }); + + it('returns identical solutions with tracing off (behavioral parity)', () => { + const equations = ['x - 5', '2*x + 4', 'x + 3 = 7', 'x^2 - 4', 'x^2 - 4*x + 4', 'x^2 + 1']; + for (const equation of equations) { + const trace = new TraceCollector(); + expect(solve(equation, 'x', { trace })).toEqual(solve(equation, 'x')); + } + }); + }); + describe('Complex number', () => { it('creates complex number', () => { const c = new Complex(3, 4); diff --git a/packages/math-engine/src/solver/solve.ts b/packages/math-engine/src/solver/solve.ts index aadd394b..9c2fc421 100644 --- a/packages/math-engine/src/solver/solve.ts +++ b/packages/math-engine/src/solver/solve.ts @@ -9,10 +9,11 @@ */ import type { ExpressionNode } from '../parser/ast'; -import { createOperatorNode } from '../parser/ast'; +import { createConstantNode, createOperatorNode } from '../parser/ast'; import { evaluate } from '../parser/evaluator'; import { parse } from '../parser/parser'; import { differentiate } from '../symbolic/differentiate'; +import { formatTraceNumber, type TraceCollector } from '../trace/step-trace'; /** * Complex number for complex solutions @@ -49,47 +50,109 @@ export interface Solution { * Solve linear equation: ax + b = 0 * @returns Array of solutions */ -function solveLinear(a: number, b: number): Solution[] { +function solveLinear( + a: number, + b: number, + trace?: TraceCollector, + expr?: ExpressionNode, +): Solution[] { + if (trace && expr) { + trace.emit('linear.coefficients', expr, expr, { + a: formatTraceNumber(a), + b: formatTraceNumber(b), + }); + } + if (Math.abs(a) < 1e-10) { if (Math.abs(b) < 1e-10) { // 0 = 0, infinite solutions + if (trace && expr) trace.emit('linear.allReals', expr, expr); return [{ value: Number.POSITIVE_INFINITY, multiplicity: Number.POSITIVE_INFINITY }]; } // 0 = b (b ≠ 0), no solution + if (trace && expr) trace.emit('linear.noSolution', expr, expr, { b: formatTraceNumber(b) }); return []; } - return [{ value: -b / a, multiplicity: 1 }]; + const root = -b / a; + if (trace && expr) { + trace.emit('linear.divide', expr, createConstantNode(root), { + a: formatTraceNumber(a), + solution: formatTraceNumber(root), + }); + } + return [{ value: root, multiplicity: 1 }]; } /** * Solve quadratic equation: ax² + bx + c = 0 * @returns Array of solutions (may include complex numbers) */ -function solveQuadratic(a: number, b: number, c: number): Solution[] { +function solveQuadratic( + a: number, + b: number, + c: number, + trace?: TraceCollector, + expr?: ExpressionNode, +): Solution[] { if (Math.abs(a) < 1e-10) { - return solveLinear(b, c); + return solveLinear(b, c, trace, expr); } const discriminant = b * b - 4 * a * c; + if (trace && expr) { + trace.emit('quadratic.coefficients', expr, expr, { + a: formatTraceNumber(a), + b: formatTraceNumber(b), + c: formatTraceNumber(c), + }); + trace.emit('quadratic.formula', expr, expr, { + a: formatTraceNumber(a), + b: formatTraceNumber(b), + c: formatTraceNumber(c), + discriminant: formatTraceNumber(discriminant), + }); + } + if (discriminant > 1e-10) { // Two real solutions const sqrtD = Math.sqrt(discriminant); + const r1 = (-b + sqrtD) / (2 * a); + const r2 = (-b - sqrtD) / (2 * a); + if (trace && expr) { + trace.emit('quadratic.evaluateRoots', expr, createConstantNode(r1), { + r1: formatTraceNumber(r1), + r2: formatTraceNumber(r2), + }); + } return [ - { value: (-b + sqrtD) / (2 * a), multiplicity: 1 }, - { value: (-b - sqrtD) / (2 * a), multiplicity: 1 }, + { value: r1, multiplicity: 1 }, + { value: r2, multiplicity: 1 }, ]; } if (Math.abs(discriminant) < 1e-10) { // One repeated solution - return [{ value: -b / (2 * a), multiplicity: 2 }]; + const root = -b / (2 * a); + if (trace && expr) { + trace.emit('quadratic.repeatedRoot', expr, createConstantNode(root), { + r: formatTraceNumber(root), + }); + } + return [{ value: root, multiplicity: 2 }]; } // Two complex solutions const real = -b / (2 * a); const imag = Math.sqrt(-discriminant) / (2 * a); + if (trace && expr) { + trace.emit('quadratic.complexRoots', expr, expr, { + realPart: formatTraceNumber(real), + imagPart: formatTraceNumber(imag), + discriminant: formatTraceNumber(discriminant), + }); + } return [ { value: new Complex(real, imag), multiplicity: 1 }, { value: new Complex(real, -imag), multiplicity: 1 }, @@ -180,11 +243,15 @@ function solveNumerical( expr: ExpressionNode, variable: string, initialGuess = 0, + trace?: TraceCollector, tolerance = 1e-10, maxIterations = 100, ): Solution[] { try { // Newton-Raphson: x_{n+1} = x_n - f(x_n)/f'(x_n) + trace?.emit('numeric.newton', expr, expr, { + initialGuess: formatTraceNumber(initialGuess), + }); const derivative = differentiate(expr, variable); let x = initialGuess; @@ -209,6 +276,10 @@ function solveNumerical( if (Math.abs(xNew - x) < tolerance) { // Converged + trace?.emit('numeric.converged', expr, createConstantNode(xNew), { + root: formatTraceNumber(xNew), + iterations: i + 1, + }); return [{ value: xNew, multiplicity: 1 }]; } @@ -217,6 +288,7 @@ function solveNumerical( throw new Error('Newton-Raphson did not converge'); } catch (error) { + trace?.emit('numeric.failed', expr, expr); throw new Error( `Numerical solving failed: ${error instanceof Error ? error.message : 'Unknown error'}`, ); @@ -276,9 +348,9 @@ export function solveInRange( export function solve( equation: string | ExpressionNode, variable = 'x', - options: { method?: 'auto' | 'numerical'; initialGuess?: number } = {}, + options: { method?: 'auto' | 'numerical'; initialGuess?: number; trace?: TraceCollector } = {}, ): Solution[] { - const { method = 'auto', initialGuess = 0 } = options; + const { method = 'auto', initialGuess = 0, trace } = options; // Parse equation if string let expr: ExpressionNode; @@ -308,19 +380,19 @@ export function solve( // Try linear const linearCoeffs = extractLinearCoefficients(expr, variable); if (linearCoeffs) { - return solveLinear(linearCoeffs.a, linearCoeffs.b); + return solveLinear(linearCoeffs.a, linearCoeffs.b, trace, expr); } // Try quadratic const quadraticCoeffs = extractQuadraticCoefficients(expr, variable); if (quadraticCoeffs) { - return solveQuadratic(quadraticCoeffs.a, quadraticCoeffs.b, quadraticCoeffs.c); + return solveQuadratic(quadraticCoeffs.a, quadraticCoeffs.b, quadraticCoeffs.c, trace, expr); } // Fall back to numerical - return solveNumerical(expr, variable, initialGuess); + return solveNumerical(expr, variable, initialGuess, trace); } // Force numerical - return solveNumerical(expr, variable, initialGuess); + return solveNumerical(expr, variable, initialGuess, trace); } diff --git a/packages/math-engine/src/symbolic/limits.ts b/packages/math-engine/src/symbolic/limits.ts index 06998d32..a07f0b40 100644 --- a/packages/math-engine/src/symbolic/limits.ts +++ b/packages/math-engine/src/symbolic/limits.ts @@ -24,6 +24,7 @@ import { isSymbolNode, } from '../parser/ast'; import { evaluate } from '../parser/evaluator'; +import { formatTraceNumber, type TraceCollector } from '../trace/step-trace'; import { differentiate } from './differentiate'; import { maclaurinSeries, taylorSeries } from './series'; import { astEquals, simplify, substitute } from './simplify'; @@ -73,6 +74,11 @@ export interface LimitConfig { maxLhopitalIterations?: number; /** Include step-by-step explanation (default: false) */ includeSteps?: boolean; + /** + * Structured step tracing (opt-in). When provided, the computation emits + * {ruleId, params, before, after} trace steps. Costs nothing when omitted. + */ + trace?: TraceCollector; /** @internal Skip algebraic simplification to prevent recursion */ _skipAlgebraic?: boolean; } @@ -128,6 +134,7 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi tolerance = 1e-10, maxLhopitalIterations = 5, includeSteps = false, + trace, } = config; const steps: string[] = []; @@ -136,6 +143,17 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi steps.push(`Computing lim (${variable}→${formatPoint(point)}) of expression`); } + // Announce the limit once — the internal recursion after algebraic + // simplification (marked by _skipAlgebraic) shares the same collector + // and must not emit a second setup step. + if (!config._skipAlgebraic) { + trace?.emit('limit.setup', expr, expr, { + variable, + point: formatPoint(point), + direction, + }); + } + // Step 1: Check for known patterns before direct substitution. // Pattern recognition must run first because some indeterminate forms // (e.g. (1+1/x)^x as x→∞) constant-fold numerically to a finite value @@ -143,6 +161,10 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi // return method: 'direct' instead of the correct method: 'pattern'. const patternResult = tryKnownPatterns(expr, variable, point, steps, includeSteps); if (patternResult.success) { + trace?.emit('limit.pattern', expr, limitValueToNode(patternResult.value), { + pattern: patternResult.pattern ?? '', + value: formatValueParam(patternResult.value), + }); return { value: patternResult.value, exists: true, @@ -155,6 +177,9 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi // Step 2: Try direct substitution const directResult = tryDirectSubstitution(expr, variable, point, steps, includeSteps); if (directResult.success) { + trace?.emit('limit.direct', expr, limitValueToNode(directResult.value), { + value: formatValueParam(directResult.value), + }); return { value: directResult.value, exists: directResult.value !== 'undefined' && directResult.value !== 'DNE', @@ -167,6 +192,9 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi if (!config._skipAlgebraic) { const algebraicResult = tryAlgebraicSimplification(expr, variable, point, steps, includeSteps); if (algebraicResult.success) { + trace?.emit('limit.simplify', expr, algebraicResult.simplified); + // NOTE: `...config` deliberately carries `trace` through the recursion + // so nested L'Hôpital / series steps keep collecting. return limit(algebraicResult.simplified, variable, { ...config, includeSteps: false, @@ -177,7 +205,11 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi // Step 4: Apply L'Hôpital's rule for indeterminate forms const indeterminateForm = detectIndeterminateForm(directResult); + if (indeterminateForm !== 'none') { + trace?.emit('limit.indeterminate', expr, expr, { form: indeterminateForm }); + } if (indeterminateForm === '0/0' || indeterminateForm === '∞/∞') { + trace?.emit('limit.lhopital', expr, expr, { form: indeterminateForm }); const lhopitalResult = tryLhopitalsRule( expr, variable, @@ -185,6 +217,7 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi maxLhopitalIterations, steps, includeSteps, + trace, ); if (lhopitalResult.success) { return { @@ -204,8 +237,11 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi // (e.g. when symbolic differentiation produces expressions whose evaluation // still requires the numerical evaluator fallback). if (typeof point === 'number') { - const seriesResult = trySeriesExpansion(expr, variable, point, steps, includeSteps); + const seriesResult = trySeriesExpansion(expr, variable, point, steps, includeSteps, trace); if (seriesResult.success) { + trace?.emit('limit.seriesResult', expr, limitValueToNode(seriesResult.value), { + value: formatValueParam(seriesResult.value), + }); return { value: seriesResult.value, exists: true, @@ -217,6 +253,7 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi } // Step 6: Numerical approximation (fallback) + trace?.emit('limit.numerical', expr, expr); const numericalResult = tryNumericalApproximation( expr, variable, @@ -227,6 +264,12 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi includeSteps, ); + if (numericalResult.value !== 'undefined' && numericalResult.value !== 'DNE') { + trace?.emit('limit.numericalResult', expr, limitValueToNode(numericalResult.value), { + value: formatValueParam(numericalResult.value), + }); + } + return { value: numericalResult.value, exists: numericalResult.value !== 'undefined' && numericalResult.value !== 'DNE', @@ -235,6 +278,22 @@ export function limit(expr: ExpressionNode, variable: string, config: LimitConfi }; } +/** + * Convert a limit value into an AST node for trace `after` fields. + * Non-numeric values map to the corresponding IEEE constants so that the + * node is still a plain ConstantNode (NaN encodes "undefined"/"DNE"). + */ +function formatValueParam(value: LimitValue): string { + return typeof value === 'number' ? formatTraceNumber(value) : formatLimitValue(value); +} + +function limitValueToNode(value: LimitValue): ExpressionNode { + if (typeof value === 'number') return createConstantNode(value); + if (value === 'infinity') return createConstantNode(Number.POSITIVE_INFINITY); + if (value === '-infinity') return createConstantNode(Number.NEGATIVE_INFINITY); + return createConstantNode(Number.NaN); +} + // ============================================================================ // DIRECT SUBSTITUTION // ============================================================================ @@ -435,6 +494,7 @@ function tryLhopitalsRule( maxIterations: number, steps: string[], includeSteps: boolean, + trace?: TraceCollector, ): LhopitalResult { // L'Hôpital's rule only applies to quotients if (!isOperatorNode(expr) || expr.op !== '/') { @@ -449,6 +509,7 @@ function tryLhopitalsRule( let currentNum = numerator; let currentDen = denominator; + let previousQuotient: ExpressionNode = expr; for (let i = 0; i < maxIterations; i++) { // Differentiate numerator and denominator @@ -462,17 +523,28 @@ function tryLhopitalsRule( // Try direct substitution const newExpr = createOperatorNode('/', 'divide', [currentNum, currentDen]); + trace?.emit('limit.lhopitalDifferentiate', previousQuotient, newExpr, { + iteration: i + 1, + }); + previousQuotient = newExpr; const result = tryDirectSubstitution(newExpr, variable, point, steps, false); if (result.success) { if (includeSteps) { steps.push(`L'Hôpital's rule succeeded: ${formatLimitValue(result.value)}`); } + trace?.emit('limit.lhopitalResult', newExpr, limitValueToNode(result.value), { + value: formatValueParam(result.value), + }); return { success: true, value: result.value }; } // Check if we still have an indeterminate form const form = detectIndeterminateForm(result); + trace?.emit('limit.lhopitalIterationCheck', newExpr, newExpr, { + iteration: i + 1, + form, + }); if (form !== '0/0' && form !== '∞/∞') { // No longer an indeterminate form, but substitution failed break; @@ -535,6 +607,7 @@ function trySeriesExpansion( point: number, steps: string[], includeSteps: boolean, + trace?: TraceCollector, ): SeriesExpansionResult { const SERIES_TERMS = 8; @@ -559,7 +632,9 @@ function trySeriesExpansion( : taylorSeries(denominator, variable, { center: point, terms: SERIES_TERMS }); // Find the lowest-power non-zero term in each expansion. + trace?.emit('limit.seriesTermProbe', numerator, numerator, { part: 'numerator' }); const numLeading = extractLeadingTerm(numSeries.terms, variable, point); + trace?.emit('limit.seriesTermProbe', denominator, denominator, { part: 'denominator' }); const denLeading = extractLeadingTerm(denSeries.terms, variable, point); if (numLeading === null || denLeading === null) { @@ -569,6 +644,13 @@ function trySeriesExpansion( return { success: false, value: 'undefined' }; } + trace?.emit('limit.series', expr, expr, { + numPower: numLeading.power, + denPower: denLeading.power, + numCoefficient: formatTraceNumber(numLeading.coefficient), + denCoefficient: formatTraceNumber(denLeading.coefficient), + }); + if (includeSteps) { steps.push( `Series expansion: numerator leading term O((x-a)^${numLeading.power}), ` + @@ -765,6 +847,8 @@ interface PatternResult { success: boolean; value: LimitValue; form?: IndeterminateForm; + /** Human-readable pattern name (plain text, ICU-safe) */ + pattern?: string; } /** @@ -784,7 +868,7 @@ function tryKnownPatterns( if (includeSteps) { steps.push('Recognized pattern: sin(x)/x → 1 as x → 0'); } - return { success: true, value: 1, form: '0/0' }; + return { success: true, value: 1, form: '0/0', pattern: `sin(${variable})/${variable}` }; } // Pattern: lim (x→0) (1-cos(x))/x = 0 @@ -793,7 +877,12 @@ function tryKnownPatterns( if (includeSteps) { steps.push('Recognized pattern: (1-cos(x))/x → 0 as x → 0'); } - return { success: true, value: 0, form: '0/0' }; + return { + success: true, + value: 0, + form: '0/0', + pattern: `(1 - cos(${variable}))/${variable}`, + }; } // Pattern: lim (x→0) tan(x)/x = 1 @@ -802,7 +891,7 @@ function tryKnownPatterns( if (includeSteps) { steps.push('Recognized pattern: tan(x)/x → 1 as x → 0'); } - return { success: true, value: 1, form: '0/0' }; + return { success: true, value: 1, form: '0/0', pattern: `tan(${variable})/${variable}` }; } } @@ -813,7 +902,12 @@ function tryKnownPatterns( if (includeSteps) { steps.push('Recognized pattern: (1 + 1/x)^x → e as x → ∞'); } - return { success: true, value: Math.E, form: '1^∞' }; + return { + success: true, + value: Math.E, + form: '1^∞', + pattern: `(1 + 1/${variable})^${variable}`, + }; } } diff --git a/packages/math-engine/src/symbolic/step-solver.test.ts b/packages/math-engine/src/symbolic/step-solver.test.ts new file mode 100644 index 00000000..451a8499 --- /dev/null +++ b/packages/math-engine/src/symbolic/step-solver.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for the step-by-step solver: ruleId tagging (i18n addressability), + * the rational-equation path, and limitWithSteps. + */ + +import { describe, expect, it } from 'vitest'; +import { Complex, type Solution } from '../solver/solve'; +import type { TraceRuleId } from '../trace/step-trace'; +import { limitWithSteps, type SolutionStep, StepCategory, solveWithSteps } from './step-solver'; + +function ruleIds(steps: ReadonlyArray): ReadonlyArray { + return steps.map((s) => s.ruleId); +} + +function numericValues(answer: unknown): number[] { + const solutions = answer as ReadonlyArray; + return solutions + .map((s) => s.value) + .filter((v): v is number => typeof v === 'number') + .sort((a, b) => a - b); +} + +describe('solveWithSteps — ruleId tagging', () => { + it("tags every step of the linear equation '2*x + 3 = 7'", () => { + const solution = solveWithSteps('2*x + 3 = 7'); + + // Every equation step is i18n-addressable + for (const step of solution.steps) { + expect(step.ruleId, `step ${step.stepNumber} (${step.operation})`).toBeDefined(); + expect(step.params).toBeDefined(); + } + + const ids = ruleIds(solution.steps); + expect(ids).toContain('equation.start'); + expect(ids).toContain('equation.moveTermsLeft'); + expect(ids).toContain('equation.classify.linear'); + expect(ids).toContain('linear.coefficients'); + expect(ids).toContain('linear.isolate'); + expect(ids).toContain('linear.divide'); + expect(ids).toContain('answer.single'); + + expect(numericValues(solution.answer)).toEqual([2]); + }); + + it("traces factoring for 'x^2 + 5*x + 6 = 0'", () => { + const solution = solveWithSteps('x^2 + 5*x + 6 = 0'); + + const ids = ruleIds(solution.steps); + expect(ids).toContain('equation.classify.quadratic'); + expect(ids).toContain('quadratic.coefficients'); + expect(ids).toContain('quadratic.factor'); + expect(ids).toContain('quadratic.zeroProduct'); + expect(ids).toContain('quadratic.solveFactors'); + expect(ids).toContain('answer.multiple'); + + expect(numericValues(solution.answer)).toEqual([-3, -2]); + }); + + it("traces the quadratic formula for 'x^2 - 3*x + 1 = 0'", () => { + const solution = solveWithSteps('x^2 - 3*x + 1 = 0'); + + const ids = ruleIds(solution.steps); + expect(ids).toContain('quadratic.formula'); + expect(ids).toContain('quadratic.evaluateRoots'); + // Irrational roots — factoring must NOT be claimed + expect(ids).not.toContain('quadratic.factor'); + + const values = numericValues(solution.answer); + expect(values[0]).toBeCloseTo((3 - Math.sqrt(5)) / 2, 8); + expect(values[1]).toBeCloseTo((3 + Math.sqrt(5)) / 2, 8); + }); + + it("traces complex roots for 'x^2 + 1 = 0'", () => { + const solution = solveWithSteps('x^2 + 1 = 0'); + + const ids = ruleIds(solution.steps); + expect(ids).toContain('quadratic.complexRoots'); + + const solutions = solution.answer as ReadonlyArray; + expect(solutions).toHaveLength(2); + expect(solutions[0]?.value).toBeInstanceOf(Complex); + }); +}); + +describe('solveWithSteps — rational equations', () => { + it("solves '1/(x - 2) + 3 = 5' with domain and LCD steps", () => { + const solution = solveWithSteps('1/(x - 2) + 3 = 5'); + + const ids = ruleIds(solution.steps); + expect(ids).toContain('equation.classify.rational'); + expect(ids).toContain('rational.domain'); + expect(ids).toContain('rational.multiplyLcd'); + expect(ids).toContain('rational.checkExtraneous'); + // Root 2.5 does not zero the denominator — nothing is extraneous + expect(ids).not.toContain('rational.extraneousExcluded'); + + expect(numericValues(solution.answer)).toEqual([2.5]); + }); + + it("excludes the extraneous root of 'x/(x - 2) - 2/(x - 2) = 0'", () => { + const solution = solveWithSteps('x/(x - 2) - 2/(x - 2) = 0'); + + const ids = ruleIds(solution.steps); + expect(ids).toContain('equation.classify.rational'); + expect(ids).toContain('rational.domain'); + expect(ids).toContain('rational.extraneousExcluded'); + expect(ids).toContain('answer.none'); + + const excluded = solution.steps.find((s) => s.ruleId === 'rational.extraneousExcluded'); + expect(excluded?.params?.['root']).toBe('2'); + + expect(solution.answer).toEqual([]); + }); +}); + +describe('step params — ICU safety', () => { + it('never contains braces or backslashes (LaTeX must stay out of params)', () => { + const problems = [ + '2*x + 3 = 7', + 'x^2 + 5*x + 6 = 0', + 'x^2 - 3*x + 1 = 0', + 'x^2 + 1 = 0', + '1/(x - 2) + 3 = 5', + 'x/(x - 2) - 2/(x - 2) = 0', + 'x^3 - 6*x^2 + 11*x - 6 = 0', + 'sin(x) - 0.5 = 0', + ]; + + const allSteps: SolutionStep[] = problems.flatMap((p) => [...solveWithSteps(p).steps]); + allSteps.push(...limitWithSteps('sin(x)/x', 'x', { point: 0 }).steps); + allSteps.push(...limitWithSteps('(exp(x) - 1)/x', 'x', { point: 0 }).steps); + + expect(allSteps.length).toBeGreaterThan(0); + for (const step of allSteps) { + for (const [key, value] of Object.entries(step.params ?? {})) { + const text = String(value); + expect(text, `param ${key} of ${step.ruleId}`).not.toMatch(/[{}\\]/); + } + } + }); +}); + +describe('limitWithSteps', () => { + it('resolves sin(x)/x at 0 via the known pattern', () => { + const solution = limitWithSteps('sin(x)/x', 'x', { point: 0 }); + + expect(solution.problemType).toBe('Limit'); + expect(solution.answer).toBe(1); + + const ids = ruleIds(solution.steps); + expect(ids).toContain('limit.setup'); + expect(ids).toContain('limit.pattern'); + expect(ids[ids.length - 1]).toBe('limit.value'); + }); + + it("resolves (exp(x) - 1)/x at 0 via L'Hôpital with a differentiate step", () => { + const solution = limitWithSteps('(exp(x) - 1)/x', 'x', { point: 0 }); + + expect(solution.answer).toBe(1); + + const ids = ruleIds(solution.steps); + expect(ids).toContain('limit.lhopital'); + expect(ids).toContain('limit.lhopitalDifferentiate'); + expect(ids[ids.length - 1]).toBe('limit.value'); + }); + + it('resolves x^2 + 1 at 2 by direct substitution', () => { + const solution = limitWithSteps('x^2 + 1', 'x', { point: 2 }); + + expect(solution.answer).toBe(5); + expect(ruleIds(solution.steps)).toContain('limit.direct'); + }); + + it('resolves 1/x at infinity to 0', () => { + const solution = limitWithSteps('1/x', 'x', { point: 'infinity' }); + expect(solution.answer).toBe(0); + }); + + it('marks steps with the Limit category and \\lim LaTeX notation', () => { + const solution = limitWithSteps('sin(x)/x', 'x', { point: 0 }); + + const [first, ...rest] = solution.steps; + const final = rest[rest.length - 1] ?? first; + + expect(first?.category).toBe(StepCategory.Limit); + expect(final?.category).toBe(StepCategory.FinalAnswer); + + for (const step of solution.steps) { + expect(step.latex).toContain('\\lim_{x \\to 0}'); + } + expect(final?.latex).toContain('= 1'); + }); + + it('renders one-sided limits with a direction superscript', () => { + const solution = limitWithSteps('x^2 + 1', 'x', { point: 2, direction: 'right' }); + expect(solution.steps[0]?.latex).toContain('2^{+}'); + expect(solution.answer).toBe(5); + }); +}); diff --git a/packages/math-engine/src/symbolic/step-solver.ts b/packages/math-engine/src/symbolic/step-solver.ts index d290bb86..5e186ee2 100644 --- a/packages/math-engine/src/symbolic/step-solver.ts +++ b/packages/math-engine/src/symbolic/step-solver.ts @@ -27,9 +27,18 @@ import { import { evaluate } from '../parser/evaluator'; import { parse } from '../parser/parser'; import { Complex, type Solution, solve } from '../solver/solve'; +import { + curateTrace, + formatTraceNumber, + TraceCollector, + type TraceParams, + type TraceRuleId, + type TraceStep, +} from '../trace/step-trace'; import { differentiate } from './differentiate'; import { analyzeExpression } from './expression-tree'; import { astToString } from './integrate'; +import { type LimitDirection, type LimitPoint, limit } from './limits'; import { astEquals, expand, simplify } from './simplify'; /** @@ -52,6 +61,16 @@ export interface SolutionStep { readonly category: StepCategory; /** LaTeX representation of the step */ readonly latex?: string; + /** + * Stable rule identifier — makes the step i18n-addressable + * (`solver.stepRules..{title,detail}` on the web layer). + */ + readonly ruleId?: TraceRuleId; + /** + * Plain-value interpolation params for the localized rule description. + * Always plain strings/numbers (never LaTeX — braces break ICU). + */ + readonly params?: TraceParams; } /** @@ -70,6 +89,7 @@ export const StepCategory = { Evaluation: 'Evaluation', Identity: 'Identity', Formula: 'Formula', + Limit: 'Limit', FinalAnswer: 'FinalAnswer', } as const; export type StepCategory = (typeof StepCategory)[keyof typeof StepCategory]; @@ -113,18 +133,98 @@ type EquationType = | 'quadratic' | 'cubic' | 'higher-polynomial' + | 'rational' | 'trigonometric' | 'exponential' | 'logarithmic' | 'transcendental'; +/** Does `node` (or any descendant) reference the variable? */ +function containsVariable(node: ExpressionNode, variable: string): boolean { + if (isSymbolNode(node)) return node.name === variable; + if (node.args) { + for (const arg of node.args) { + if (containsVariable(arg, variable)) return true; + } + } + return false; +} + +/** + * True when the expression contains a division whose denominator involves + * the variable — the marker of a rational equation. + */ +function hasVariableDenominator(expr: ExpressionNode, variable: string): boolean { + if (isOperatorNode(expr) && expr.op === '/') { + const denominator = expr.args[1]; + if (containsVariable(denominator, variable)) return true; + } + if (expr.args) { + for (const arg of expr.args) { + if (hasVariableDenominator(arg, variable)) return true; + } + } + return false; +} + +/** + * Collect every distinct denominator sub-expression that contains the + * variable (deduplicated by their string form). + */ +function collectVariableDenominators(expr: ExpressionNode, variable: string): ExpressionNode[] { + const found = new Map(); + + const walk = (node: ExpressionNode): void => { + if (isOperatorNode(node) && node.op === '/') { + const denominator = node.args[1]; + if (containsVariable(denominator, variable)) { + found.set(astToString(denominator), denominator); + } + } + if (node.args) { + for (const arg of node.args) walk(arg); + } + }; + + walk(expr); + return [...found.values()]; +} + +/** + * Sample offsets used for finite-difference degree detection. The extra + * non-integer offsets let classification succeed for expressions with a + * pole at an integer sample point (e.g. the LCD product of a rational + * equation, which is undefined exactly at the excluded values). + */ +const SAMPLE_OFFSETS = [0, 0.1357, 0.2468] as const; + /** * Detect the degree / family of a polynomial-like residual expression. * The `expr` here is the LHS after moving everything to the left (LHS − RHS). + * + * @param checkRational - when false, skip the rational-equation check + * (used when reclassifying the already-multiplied LCD product, which may + * still contain division nodes symbolically but is polynomial numerically). */ -function detectEquationType(expr: ExpressionNode, variable: string): EquationType { - // Check for transcendental functions first +function detectEquationType( + expr: ExpressionNode, + variable: string, + checkRational = true, +): EquationType { const exprStr = astToString(expr); + + // Rational check runs first: a variable in a denominator makes the finite + // difference sampling below unreliable (poles). Denominators containing + // transcendental functions stay on the Newton path by design. + if ( + checkRational && + hasVariableDenominator(expr, variable) && + !/\b(sin|cos|tan|exp|log|ln)\b/.test(exprStr) + ) { + return 'rational'; + } + + // Check for transcendental functions if (/\b(sin|cos|tan|asin|acos|atan|sinh|cosh|tanh)\b/.test(exprStr)) { return 'trigonometric'; } @@ -135,16 +235,34 @@ function detectEquationType(expr: ExpressionNode, variable: string): EquationTyp return 'logarithmic'; } - // Polynomial degree detection by sampling + // Polynomial degree detection by sampling. Retry with shifted sample + // grids when a sample point is undefined (pole of a cancelled factor). + for (const offset of SAMPLE_OFFSETS) { + const type = classifyByFiniteDifferences(expr, variable, offset); + if (type !== null) return type; + } + return 'transcendental'; +} + +/** + * Finite-difference polynomial degree classification on the uniform grid + * `offset, offset+1, …, offset+4`. Returns null when any sample is + * undefined (caller retries with a different offset). + */ +function classifyByFiniteDifferences( + expr: ExpressionNode, + variable: string, + offset: number, +): EquationType | null { try { - const c0 = evalAt(expr, variable, 0); - const c1 = evalAt(expr, variable, 1); - const c2 = evalAt(expr, variable, 2); - const c3 = evalAt(expr, variable, 3); - const c4 = evalAt(expr, variable, 4); + const c0 = evalAt(expr, variable, offset); + const c1 = evalAt(expr, variable, offset + 1); + const c2 = evalAt(expr, variable, offset + 2); + const c3 = evalAt(expr, variable, offset + 3); + const c4 = evalAt(expr, variable, offset + 4); if (c0 === null || c1 === null || c2 === null || c3 === null || c4 === null) { - return 'transcendental'; + return null; } // Finite differences to detect degree @@ -174,7 +292,7 @@ function detectEquationType(expr: ExpressionNode, variable: string): EquationTyp return 'transcendental'; } catch { - return 'transcendental'; + return null; } } @@ -189,6 +307,24 @@ function evalAt(expr: ExpressionNode, variable: string, x: number): number | nul } } +/** Equation type → classification rule id (i18n-addressable). */ +const CLASSIFY_RULE_IDS: Record = { + linear: 'equation.classify.linear', + quadratic: 'equation.classify.quadratic', + cubic: 'equation.classify.cubic', + 'higher-polynomial': 'equation.classify.higherPolynomial', + rational: 'equation.classify.rational', + trigonometric: 'equation.classify.trigonometric', + exponential: 'equation.classify.exponential', + logarithmic: 'equation.classify.logarithmic', + transcendental: 'equation.classify.transcendental', +}; + +/** Format a solution value as a plain (ICU-safe) param string. */ +function solutionValueToParam(value: number | Complex): string { + return typeof value === 'number' ? formatTraceNumber(value) : value.toString(); +} + // ============================================================================ // LATEX HELPERS // ============================================================================ @@ -455,6 +591,7 @@ export class StepSolver { `We need to find all values of ${variable} that satisfy the equation.`, StepCategory.Identification, `${nodeToLatex(lhs)} = ${nodeToLatex(rhs)}`, + { ruleId: 'equation.start' }, ), ); @@ -474,6 +611,7 @@ export class StepSolver { `This gives: ${nodeToLatex(simplified)} = 0.`, StepCategory.Rearrangement, `${nodeToLatex(simplified)} = 0`, + { ruleId: 'equation.moveTermsLeft', params: { rhs: astToString(rhs) } }, ), ); } @@ -490,6 +628,7 @@ export class StepSolver { this.equationTypeExplanation(eqType, variable), StepCategory.Identification, `${nodeToLatex(simplified)} = 0`, + { ruleId: CLASSIFY_RULE_IDS[eqType] }, ), ); @@ -506,6 +645,9 @@ export class StepSolver { case 'cubic': solutions = this.solveCubicWithSteps(simplified, variable, steps); break; + case 'rational': + solutions = this.solveRationalWithSteps(simplified, variable, steps); + break; case 'trigonometric': solutions = this.solveTrigWithSteps(simplified, variable, steps, problem); break; @@ -516,6 +658,7 @@ export class StepSolver { // Final answer step const finalLatex = solutionsToLatex(solutions, variable); + const solutionsParam = solutions.map((s) => solutionValueToParam(s.value)).join(', '); steps.push( this.createStep( simplified, @@ -531,6 +674,14 @@ export class StepSolver { : `The solution${solutions.length > 1 ? 's are' : ' is'}: ${finalLatex}`, StepCategory.FinalAnswer, finalLatex, + solutions.length === 0 + ? { ruleId: 'answer.none' } + : solutions.length === 1 + ? { ruleId: 'answer.single', params: { solution: solutionsParam, variable } } + : { + ruleId: 'answer.multiple', + params: { count: solutions.length, solutions: solutionsParam, variable }, + }, ), ); @@ -565,6 +716,10 @@ export class StepSolver { `Here a = ${a} (coefficient of ${variable}) and b = ${b} (constant term).`, StepCategory.Identification, `${a} \\cdot ${variable} + \\left(${b}\\right) = 0`, + { + ruleId: 'linear.coefficients', + params: { a: formatTraceNumber(a), b: formatTraceNumber(b), variable }, + }, ), ); @@ -582,6 +737,9 @@ export class StepSolver { : `Both coefficients are 0, so every value of ${variable} is a solution.`, StepCategory.FinalAnswer, noSol ? '\\text{No solution}' : `${variable} \\in \\mathbb{R}`, + noSol + ? { ruleId: 'linear.noSolution', params: { b: formatTraceNumber(b) } } + : { ruleId: 'linear.allReals' }, ), ); return solve(expr, variable); @@ -597,6 +755,10 @@ export class StepSolver { `Subtract the constant term from both sides: ${a} ${variable} = ${-b}.`, StepCategory.Isolation, `${a} ${variable} = ${-b}`, + { + ruleId: 'linear.isolate', + params: { a: formatTraceNumber(a), negB: formatTraceNumber(-b), variable }, + }, ), ); @@ -613,6 +775,10 @@ export class StepSolver { `Dividing both sides of the equation by the coefficient ${a} isolates ${variable}: ${variable} = ${xVal}.`, StepCategory.Isolation, `${variable} = ${xLatex}`, + { + ruleId: 'linear.divide', + params: { a: formatTraceNumber(a), solution: formatTraceNumber(xVal), variable }, + }, ), ); @@ -650,6 +816,15 @@ export class StepSolver { `Here a = ${a}, b = ${b}, c = ${c}.`, StepCategory.Identification, `${aStr}${variable}^{2} ${b >= 0 ? '+' : ''} ${bStr}${variable} ${c >= 0 ? '+' : ''} ${cStr} = 0`, + { + ruleId: 'quadratic.coefficients', + params: { + a: formatTraceNumber(a), + b: formatTraceNumber(b), + c: formatTraceNumber(c), + variable, + }, + }, ), ); @@ -683,6 +858,16 @@ export class StepSolver { `The expression factors as ${a !== 1 ? `${a}·` : ''}(${variable} ${r1 <= 0 ? '+' : '−'} ${Math.abs(r1)})(${variable} ${r2 <= 0 ? '+' : '−'} ${Math.abs(r2)}).`, StepCategory.Factorization, r1 === r2 ? `${aPrefix}${f1Latex}^{2} = 0` : `${aPrefix}${f1Latex}${f2Latex} = 0`, + { + ruleId: 'quadratic.factor', + params: { + ac: formatTraceNumber(a * c), + b: formatTraceNumber(b), + r1: formatTraceNumber(r1), + r2: formatTraceNumber(r2), + variable, + }, + }, ), ); @@ -698,6 +883,10 @@ export class StepSolver { `So either ${variable} ${r1 <= 0 ? '+' : '−'} ${Math.abs(r1)} = 0 or ${variable} ${r2 <= 0 ? '+' : '−'} ${Math.abs(r2)} = 0.`, StepCategory.Isolation, `${variable} ${r1 <= 0 ? '+' : '-'} ${Math.abs(r1)} = 0 \\quad \\text{or} \\quad ${variable} ${r2 <= 0 ? '+' : '-'} ${Math.abs(r2)} = 0`, + { + ruleId: 'quadratic.zeroProduct', + params: { r1: formatTraceNumber(r1), r2: formatTraceNumber(r2), variable }, + }, ), ); @@ -710,6 +899,10 @@ export class StepSolver { `Solving the first factor: ${variable} = ${r1}. Solving the second factor: ${variable} = ${r2}.`, StepCategory.Isolation, `${variable} = ${r1} \\quad \\text{or} \\quad ${variable} = ${r2}`, + { + ruleId: 'quadratic.solveFactors', + params: { r1: formatTraceNumber(r1), r2: formatTraceNumber(r2), variable }, + }, ), ); } else { @@ -722,6 +915,10 @@ export class StepSolver { `Since the discriminant is 0, both roots are equal: ${variable} = ${r1} (multiplicity 2).`, StepCategory.Isolation, `${variable} = ${r1} \\quad (\\text{multiplicity } 2)`, + { + ruleId: 'quadratic.repeatedRoot', + params: { r: formatTraceNumber(r1), variable }, + }, ), ); } @@ -747,6 +944,15 @@ export class StepSolver { `${discriminant >= 0 ? 'is non-negative, the formula gives real solutions' : 'is negative, the solutions are complex'}.`, StepCategory.Formula, `${variable} = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a} = \\frac{${-b} \\pm \\sqrt{${discriminant}}}{${2 * a}}`, + { + ruleId: 'quadratic.formula', + params: { + a: formatTraceNumber(a), + b: formatTraceNumber(b), + c: formatTraceNumber(c), + discriminant: formatTraceNumber(discriminant), + }, + }, ), ); @@ -765,6 +971,14 @@ export class StepSolver { `and ${variable}₂ = ${r2exact.toFixed(6).replace(/\.?0+$/, '')}.`, StepCategory.Evaluation, `${variable}_1 = ${formatDecimal(r1exact)}, \\quad ${variable}_2 = ${formatDecimal(r2exact)}`, + { + ruleId: 'quadratic.evaluateRoots', + params: { + r1: formatTraceNumber(r1exact), + r2: formatTraceNumber(r2exact), + variable, + }, + }, ), ); @@ -791,6 +1005,15 @@ export class StepSolver { `The two complex conjugate solutions are ${variable} = ${formatDecimal(realPart)} ± ${formatDecimal(imagPart)}i.`, StepCategory.Evaluation, `${variable} = ${formatDecimal(realPart)} \\pm ${formatDecimal(imagPart)}i`, + { + ruleId: 'quadratic.complexRoots', + params: { + realPart: formatTraceNumber(realPart), + imagPart: formatTraceNumber(imagPart), + discriminant: formatTraceNumber(discriminant), + variable, + }, + }, ), ); @@ -831,6 +1054,16 @@ export class StepSolver { `with a = ${formatDecimal(a)}, b = ${formatDecimal(b)}, c = ${formatDecimal(c)}, d = ${formatDecimal(d)}.`, StepCategory.Identification, `${formatCoeff(a)}${variable}^{3} + ${formatCoeff(b)}${variable}^{2} + ${formatCoeff(c)}${variable} + ${formatDecimal(d)} = 0`, + { + ruleId: 'cubic.coefficients', + params: { + a: formatTraceNumber(a), + b: formatTraceNumber(b), + c: formatTraceNumber(c), + d: formatTraceNumber(d), + variable, + }, + }, ), ); @@ -848,6 +1081,14 @@ export class StepSolver { `t³ + pt + q = 0 where p = ${formatDecimal(p)} and q = ${formatDecimal(q)}.`, StepCategory.Substitution, `t^3 + ${formatDecimal(p)} \\cdot t + ${formatDecimal(q)} = 0`, + { + ruleId: 'cubic.depress', + params: { + shift: formatTraceNumber(b / (3 * a)), + p: formatTraceNumber(p), + q: formatTraceNumber(q), + }, + }, ), ); @@ -868,6 +1109,7 @@ export class StepSolver { : 'Since Δ < 0, the cubic has one real root and two complex conjugate roots.'), StepCategory.Identification, `\\Delta = -4p^3 - 27q^2 = ${formatDecimal(delta)}`, + { ruleId: 'cubic.discriminant', params: { delta: formatTraceNumber(delta) } }, ), ); @@ -897,12 +1139,163 @@ export class StepSolver { '.', StepCategory.Evaluation, solutionLatex, + { + ruleId: 'cubic.roots', + params: { + roots: solutions.map((s) => solutionValueToParam(s.value)).join(', '), + variable, + }, + }, ), ); return solutions; } + // ---- Rational equation: clear denominators, delegate, check extraneous ---- + + private solveRationalWithSteps( + expr: ExpressionNode, + variable: string, + steps: SolutionStep[], + ): ReadonlyArray { + const denominators = collectVariableDenominators(expr, variable); + if (denominators.length === 0) { + // Defensive: classification said rational but no denominator found + return this.solveNumericalWithSteps(expr, variable, steps, 'rational'); + } + + // Step: domain restrictions — denominators must not vanish + const denominatorsParam = denominators.map((d) => astToString(d)).join(', '); + steps.push( + this.createStep( + expr, + expr, + 'Domain restrictions', + 'State the domain restrictions', + `Division by zero is undefined, so every denominator containing ${variable} must be non-zero: ` + + `${denominators.map((d) => `${astToString(d)} ≠ 0`).join(', ')}. ` + + `Any candidate solution violating these restrictions must be rejected as extraneous.`, + StepCategory.Identification, + denominators.map((d) => `${nodeToLatex(d)} \\neq 0`).join(' \\;,\\quad '), + { + ruleId: 'rational.domain', + params: { denominators: denominatorsParam, variable }, + }, + ), + ); + + // Step: multiply through by every distinct denominator (the LCD up to + // repeated factors) to clear the fractions. + let product: ExpressionNode = expr; + for (const d of denominators) { + product = createOperatorNode('*', 'multiply', [product, d] as const); + } + + // Best-effort cancelled form for display and delegation. Correctness + // does not depend on symbolic cancellation: the delegated solvers work + // numerically via sampling, which is valid wherever the product is + // defined (it extends continuously across the cleared poles). + let cleared: ExpressionNode = product; + try { + const simplified = simplify(expand(product)); + if (!hasVariableDenominator(simplified, variable)) { + cleared = simplified; + } + } catch { + // keep the raw product + } + + const lcdParam = denominators.map((d) => astToString(d)).join(' · '); + steps.push( + this.createStep( + expr, + cleared, + 'Multiply by LCD', + 'Multiply both sides by the denominators', + `Multiplying both sides of the equation by ${lcdParam} clears all fractions. ` + + `The equation becomes ${astToString(cleared)} = 0, which is polynomial in ${variable}.`, + StepCategory.Rearrangement, + `${nodeToLatex(cleared)} = 0`, + { ruleId: 'rational.multiplyLcd', params: { lcd: lcdParam } }, + ), + ); + + // Reclassify the cleared equation (rational check disabled: the raw + // product may still contain division nodes symbolically even though it + // is polynomial numerically) and delegate to the matching step solver. + const clearedType = detectEquationType(cleared, variable, false); + + let candidates: ReadonlyArray; + switch (clearedType) { + case 'linear': + candidates = this.solveLinearWithSteps(cleared, variable, steps, '', ''); + break; + case 'quadratic': + candidates = this.solveQuadraticWithSteps(cleared, variable, steps); + break; + case 'cubic': + candidates = this.solveCubicWithSteps(cleared, variable, steps); + break; + default: + candidates = this.solveNumericalWithSteps(cleared, variable, steps, clearedType); + break; + } + + // Extraneous-root check: evaluate every original denominator at each + // candidate; roots that zero a denominator are outside the domain. + const kept: Solution[] = []; + for (const candidate of candidates) { + if (typeof candidate.value !== 'number') { + kept.push(candidate); + continue; + } + const rootValue = candidate.value; + const zeroedDenominator = denominators.some((d) => { + const denomValue = evalAt(d, variable, rootValue); + return denomValue !== null && Math.abs(denomValue) < 1e-9; + }); + if (zeroedDenominator) { + steps.push( + this.createStep( + expr, + expr, + 'Exclude extraneous root', + `${variable} = ${formatDecimal(rootValue)} is extraneous`, + `Substituting ${variable} = ${formatDecimal(rootValue)} makes a denominator zero, ` + + `so it violates the domain restrictions and cannot be a solution of the original equation.`, + StepCategory.Evaluation, + `${variable} \\neq ${formatDecimal(rootValue)}`, + { + ruleId: 'rational.extraneousExcluded', + params: { root: formatTraceNumber(rootValue), variable }, + }, + ), + ); + } else { + kept.push(candidate); + } + } + + // Step: summarize the extraneous-root check + steps.push( + this.createStep( + expr, + expr, + 'Check candidates', + 'Verify candidates against the domain restrictions', + kept.length === candidates.length + ? `All ${candidates.length === 1 ? 'candidate satisfies' : 'candidates satisfy'} the domain restrictions — none are extraneous.` + : `${kept.length} of ${candidates.length} candidate${candidates.length === 1 ? '' : 's'} satisfy the domain restrictions; the rest are extraneous.`, + StepCategory.Evaluation, + kept.length === 0 ? '\\text{No valid solutions remain}' : solutionsToLatex(kept, variable), + { ruleId: 'rational.checkExtraneous', params: { kept: kept.length } }, + ), + ); + + return kept; + } + // ---- Trigonometric equation ---- private solveTrigWithSteps( @@ -927,6 +1320,7 @@ export class StepSolver { `We isolate the trigonometric expression and then apply the inverse ${trigFn} function.`, StepCategory.Identification, `${nodeToLatex(expr)} = 0`, + { ruleId: 'trig.identify', params: { fn: trigFn ?? 'sin', variable } }, ), ); @@ -945,6 +1339,7 @@ export class StepSolver { }.`, StepCategory.Isolation, `\\${trigFn}(${variable}) = k`, + { ruleId: 'trig.isolate', params: { fn: trigFn ?? 'sin', variable } }, ), ); @@ -968,6 +1363,7 @@ export class StepSolver { : trigFn === 'cos' ? `${variable} = \\arccos(k) + 2\\pi n \\quad \\text{or} \\quad ${variable} = -\\arccos(k) + 2\\pi n` : `${variable} = \\arctan(k) + \\pi n`, + { ruleId: 'trig.inverse', params: { fn: trigFn ?? 'sin', variable } }, ), ); @@ -1000,6 +1396,13 @@ export class StepSolver { '.', StepCategory.Evaluation, numLatex, + { + ruleId: 'trig.principal', + params: { + solutions: solutions.map((s) => solutionValueToParam(s.value)).join(', '), + variable, + }, + }, ), ); @@ -1025,10 +1428,36 @@ export class StepSolver { `starting from an initial guess, until |${variable}_{n+1} − ${variable}_n| < 10⁻¹⁰.`, StepCategory.Formula, `${variable}_{n+1} = ${variable}_n - \\frac{f(${variable}_n)}{f'(${variable}_n)}`, + { ruleId: 'numeric.newton', params: { eqType, variable } }, ), ); - const solutions = solve(expr, variable); + // Run the solver with a trace attached so the converged-iteration count + // can be surfaced in the step description. + let solutions: ReadonlyArray; + let iterations: string | number = '≤ 100'; + try { + const trace = new TraceCollector(); + solutions = solve(expr, variable, { trace }); + const converged = trace.steps.find((s) => s.ruleId === 'numeric.converged'); + const traceIterations = converged?.params['iterations']; + if (traceIterations !== undefined) iterations = traceIterations; + } catch { + steps.push( + this.createStep( + expr, + expr, + 'No convergence', + 'Newton-Raphson did not converge', + 'The Newton-Raphson iteration did not converge for the default initial guess. ' + + 'The equation may have no real solution near the starting point.', + StepCategory.Evaluation, + '\\text{No convergence}', + { ruleId: 'numeric.failed' }, + ), + ); + return []; + } const numLatex = solutions.length > 0 @@ -1052,6 +1481,13 @@ export class StepSolver { : 'The Newton-Raphson iteration did not converge for the default initial guess.', StepCategory.Evaluation, numLatex, + { + ruleId: 'numeric.converged', + params: { + solutions: solutions.map((s) => solutionValueToParam(s.value)).join(', '), + iterations, + }, + }, ), ); @@ -1066,6 +1502,7 @@ export class StepSolver { quadratic: 'Quadratic equation (degree 2)', cubic: 'Cubic equation (degree 3)', 'higher-polynomial': 'Higher-degree polynomial', + rational: 'Rational equation', trigonometric: 'Trigonometric equation', exponential: 'Exponential equation', logarithmic: 'Logarithmic equation', @@ -1080,6 +1517,7 @@ export class StepSolver { quadratic: `A quadratic equation can have 0, 1, or 2 real solutions. We try factoring first; if the discriminant is not a perfect square, we apply the quadratic formula.`, cubic: `A cubic equation has exactly 1 or 3 real solutions. We use the Tschirnhaus-Vieta substitution to convert it to a depressed cubic and apply Cardano's formula.`, 'higher-polynomial': `Higher-degree polynomial equations are solved numerically using Newton-Raphson iteration.`, + rational: `The unknown appears in a denominator. We record the domain restrictions (denominators must not be zero), multiply both sides by the denominators to clear the fractions, solve the resulting polynomial equation, and check every candidate root against the restrictions to exclude extraneous solutions.`, trigonometric: `Trigonometric equations are solved by isolating the trig function, computing the principal value using the inverse function, and then applying the appropriate periodicity formula.`, exponential: `Exponential equations are solved by taking the natural logarithm of both sides.`, logarithmic: `Logarithmic equations are solved by exponentiating both sides with the appropriate base.`, @@ -1319,6 +1757,7 @@ export class StepSolver { explanation: string, category: StepCategory, latex?: string, + meta?: { ruleId: TraceRuleId; params?: TraceParams }, ): SolutionStep { return { stepNumber: ++this.stepCounter, @@ -1329,6 +1768,7 @@ export class StepSolver { explanation, category, ...(latex ? { latex } : {}), + ...(meta ? { ruleId: meta.ruleId, params: meta.params ?? {} } : {}), }; } } @@ -1369,3 +1809,273 @@ export function solveWithSteps(problem: string, type?: ProblemType): StepSolutio const solver = createStepSolver(); return solver.solve(problem, type); } + +// ============================================================================ +// LIMITS WITH STEPS +// ============================================================================ + +/** Configuration for {@link limitWithSteps}. */ +export interface LimitWithStepsConfig { + /** The point the variable approaches */ + readonly point: LimitPoint; + /** Approach direction (default: 'both') */ + readonly direction?: LimitDirection; +} + +/** LaTeX for the approach point, with one-sided superscripts. */ +function limitPointToLatex(point: LimitPoint, direction: LimitDirection): string { + const base = + point === 'infinity' ? '\\infty' : point === '-infinity' ? '-\\infty' : formatDecimal(point); + if (direction === 'left') return `${base}^{-}`; + if (direction === 'right') return `${base}^{+}`; + return base; +} + +/** Map a plain trace value param ('∞', '-∞', 'undefined', '2.5') to LaTeX. */ +function limitValueLatex(value: string): string { + if (value === '∞') return '\\infty'; + if (value === '-∞') return '-\\infty'; + if (value === 'undefined') return '\\text{undefined}'; + return value; +} + +interface LimitStepText { + readonly operation: string; + readonly description: string; + readonly explanation: string; + readonly latex: string; +} + +/** + * English fallback text for a curated limit trace step. These strings mirror + * the localized `solver.stepRules.` messages on the web layer — the + * UI prefers the translation and falls back to these. + */ +function limitStepText(step: TraceStep, variable: string, lim: string): LimitStepText { + const str = (key: string): string => String(step.params[key] ?? ''); + const beforeLatex = `${lim} ${nodeToLatex(step.before)}`; + const afterLatex = `${lim} ${nodeToLatex(step.after)}`; + + switch (step.ruleId) { + case 'limit.setup': + return { + operation: 'Setup', + description: 'Set up the limit', + explanation: + `We evaluate the limit of the expression as ${str('variable')} approaches ${str('point')}` + + (str('direction') === 'both' ? '' : ` from the ${str('direction')}`) + + '.', + latex: beforeLatex, + }; + case 'limit.pattern': + return { + operation: 'Known limit', + description: `Recognize the standard limit ${str('pattern')}`, + explanation: + `This is the well-known limit ${str('pattern')} → ${str('value')}. ` + + 'It is a standard result that can be cited directly (provable via the squeeze theorem or series expansion).', + latex: `${beforeLatex} = ${limitValueLatex(str('value'))}`, + }; + case 'limit.direct': + return { + operation: 'Substitute', + description: 'Substitute the point directly', + explanation: `The expression is defined and continuous at the point, so the limit equals the function value: ${str('value')}.`, + latex: `${beforeLatex} = ${limitValueLatex(str('value'))}`, + }; + case 'limit.indeterminate': + return { + operation: 'Indeterminate form', + description: `Indeterminate form ${str('form')}`, + explanation: + `Direct substitution gives the indeterminate form ${str('form')}, which carries no information ` + + 'about the limit — further analysis is required.', + latex: + str('form') === '∞/∞' + ? `${beforeLatex} \\to \\tfrac{\\infty}{\\infty}` + : `${beforeLatex} \\to \\tfrac{0}{0}`, + }; + case 'limit.simplify': + return { + operation: 'Simplify', + description: 'Simplify the expression algebraically', + explanation: + 'Algebraic simplification (cancelling common factors, combining terms) produces an equivalent ' + + 'expression whose limit is easier to evaluate.', + latex: afterLatex, + }; + case 'limit.lhopital': + return { + operation: "L'Hôpital's rule", + description: "Apply L'Hôpital's rule", + explanation: + `Since the limit has the indeterminate form ${str('form')}, L'Hôpital's rule applies: ` + + 'the limit of f/g equals the limit of f′/g′ (when the latter exists).', + latex: `${lim} \\frac{f(${variable})}{g(${variable})} = ${lim} \\frac{f'(${variable})}{g'(${variable})}`, + }; + case 'limit.lhopitalDifferentiate': + return { + operation: 'Differentiate', + description: `Differentiate numerator and denominator (step ${str('iteration')})`, + explanation: + 'Differentiate the numerator and the denominator separately, then examine the limit of the new quotient.', + latex: `${beforeLatex} = ${afterLatex}`, + }; + case 'limit.lhopitalResult': + return { + operation: 'Evaluate', + description: "L'Hôpital's rule yields the limit", + explanation: `Substituting into the differentiated quotient gives ${str('value')}.`, + latex: `${beforeLatex} = ${limitValueLatex(str('value'))}`, + }; + case 'limit.series': + return { + operation: 'Series expansion', + description: 'Expand numerator and denominator as Taylor series', + explanation: + `The leading term of the numerator has order ${str('numPower')} and the denominator order ${str('denPower')}. ` + + 'Comparing leading orders (after cancelling common factors) determines the limit.', + latex: beforeLatex, + }; + case 'limit.seriesResult': + return { + operation: 'Evaluate', + description: 'Series comparison yields the limit', + explanation: `The ratio of the leading series coefficients gives ${str('value')}.`, + latex: `${beforeLatex} = ${limitValueLatex(str('value'))}`, + }; + case 'limit.numerical': + return { + operation: 'Numerical approximation', + description: 'Approach the point numerically', + explanation: + 'Symbolic methods were inconclusive; the expression is evaluated at points progressively ' + + 'closer to the limit point to detect convergence.', + latex: beforeLatex, + }; + case 'limit.numericalResult': + return { + operation: 'Evaluate', + description: 'Numerical approximation converged', + explanation: `The sampled values converge to ${str('value')}.`, + latex: `${beforeLatex} \\approx ${limitValueLatex(str('value'))}`, + }; + default: + return { + operation: 'Step', + description: step.ruleId, + explanation: '', + latex: afterLatex, + }; + } +} + +/** + * Compute a limit with textbook-shaped, i18n-addressable steps. + * + * Runs {@link limit} with a fresh {@link TraceCollector}, curates the raw + * trace (display-rule whitelist, no-op drop, simplify-run merge) and maps + * each trace step to a {@link SolutionStep} with `\lim` LaTeX notation. + * + * @param expression - Expression source, e.g. `"sin(x)/x"` + * @param variable - The limit variable, e.g. `"x"` + * @param config - Approach point and optional direction + * + * @example + * ```typescript + * const solution = limitWithSteps('sin(x)/x', 'x', { point: 0 }); + * solution.answer; // 1 + * solution.steps.map((s) => s.ruleId); // ['limit.setup', 'limit.pattern', 'limit.value'] + * ``` + */ +export function limitWithSteps( + expression: string, + variable: string, + config: LimitWithStepsConfig, +): StepSolution { + const startTime = performance.now(); + const expr = parse(expression); + const direction = config.direction ?? 'both'; + + const trace = new TraceCollector(); + const result = limit(expr, variable, { point: config.point, direction, trace }); + const curated = curateTrace(trace.steps); + + const lim = `\\lim_{${variable} \\to ${limitPointToLatex(config.point, direction)}}`; + + let stepNumber = 0; + const steps: SolutionStep[] = curated.map((traceStep) => { + stepNumber += 1; + const text = limitStepText(traceStep, variable, lim); + return { + stepNumber, + from: traceStep.before, + to: traceStep.after, + operation: text.operation, + description: text.description, + explanation: text.explanation, + category: StepCategory.Limit, + latex: text.latex, + ruleId: traceStep.ruleId, + params: traceStep.params, + }; + }); + + // Final answer step — limit.value | limit.infinite | limit.dne + const exprLatex = nodeToLatex(expr); + let finalRuleId: TraceRuleId; + let finalParams: TraceParams; + let valueLatex: string; + let description: string; + let explanation: string; + let answer: number; + + if (result.value === 'infinity' || result.value === '-infinity') { + const sign = result.value === 'infinity' ? '∞' : '-∞'; + finalRuleId = 'limit.infinite'; + finalParams = { sign }; + valueLatex = result.value === 'infinity' ? '\\infty' : '-\\infty'; + description = 'The limit is infinite'; + explanation = `The expression grows without bound: the limit is ${sign}.`; + answer = result.value === 'infinity' ? Number.POSITIVE_INFINITY : Number.NEGATIVE_INFINITY; + } else if (typeof result.value === 'number') { + const valueParam = formatTraceNumber(result.value); + finalRuleId = 'limit.value'; + finalParams = { value: valueParam }; + valueLatex = valueParam; + description = `The limit is ${valueParam}`; + explanation = `Combining the steps above, the limit evaluates to ${valueParam}.`; + answer = result.value; + } else { + finalRuleId = 'limit.dne'; + finalParams = {}; + valueLatex = '\\text{DNE}'; + description = 'The limit does not exist'; + explanation = + 'No finite or infinite limit could be established — the limit does not exist (or could not be determined).'; + answer = Number.NaN; + } + + stepNumber += 1; + steps.push({ + stepNumber, + from: expr, + to: createConstantNode(answer), + operation: 'Final Answer', + description, + explanation, + category: StepCategory.FinalAnswer, + latex: `${lim} ${exprLatex} = ${valueLatex}`, + ruleId: finalRuleId, + params: finalParams, + }); + + const endTime = performance.now(); + return { + problem: expression, + problemType: ProblemType.Limit, + steps, + answer, + timeMs: endTime - startTime, + }; +} diff --git a/packages/math-engine/src/trace/index.ts b/packages/math-engine/src/trace/index.ts new file mode 100644 index 00000000..2a7a84b7 --- /dev/null +++ b/packages/math-engine/src/trace/index.ts @@ -0,0 +1 @@ +export * from './step-trace'; diff --git a/packages/math-engine/src/trace/step-trace.test.ts b/packages/math-engine/src/trace/step-trace.test.ts new file mode 100644 index 00000000..a90b90c3 --- /dev/null +++ b/packages/math-engine/src/trace/step-trace.test.ts @@ -0,0 +1,210 @@ +/** + * Tests for the StepTrace module: collector semantics and trace curation + * (display whitelist, no-op drop, consecutive-simplify merge). + */ + +import { describe, expect, it } from 'vitest'; +import { createConstantNode, createSymbolNode } from '../parser/ast'; +import { parse } from '../parser/parser'; +import { + curateTrace, + DISPLAY_RULES, + formatTraceNumber, + RULE_IDS, + TraceCollector, + type TraceRuleId, + type TraceStep, +} from './step-trace'; + +const x = createSymbolNode('x'); +const one = createConstantNode(1); +const two = createConstantNode(2); + +function step(ruleId: TraceRuleId, before = x, after = x): TraceStep { + return { ruleId, params: {}, before, after }; +} + +describe('TraceCollector', () => { + it('accumulates typed steps in emission order', () => { + const trace = new TraceCollector(); + expect(trace.steps).toHaveLength(0); + + trace.emit('equation.start', x, x); + trace.emit('linear.divide', x, two, { a: '2', solution: '2' }); + + expect(trace.steps).toHaveLength(2); + expect(trace.steps[0]?.ruleId).toBe('equation.start'); + expect(trace.steps[0]?.params).toEqual({}); + expect(trace.steps[1]?.ruleId).toBe('linear.divide'); + expect(trace.steps[1]?.params).toEqual({ a: '2', solution: '2' }); + expect(trace.steps[1]?.before).toBe(x); + expect(trace.steps[1]?.after).toBe(two); + }); +}); + +describe('curateTrace', () => { + it('drops non-whitelisted (internal) rule ids', () => { + const curated = curateTrace([ + step('limit.setup'), + step('limit.lhopitalIterationCheck'), + step('limit.seriesTermProbe'), + step('simplify.rewrite', one, two), + step('limit.lhopitalResult', x, one), + ]); + + expect(curated.map((s) => s.ruleId)).toEqual(['limit.setup', 'limit.lhopitalResult']); + }); + + it('drops no-op transformation steps (before ≡ after)', () => { + const sinOverX = parse('sin(x)/x'); + const sameAst = parse('sin(x)/x'); + + const curated = curateTrace([ + step('limit.simplify', sinOverX, sameAst), // no-op transform → dropped + step('limit.lhopitalDifferentiate', sinOverX, sameAst), // no-op transform → dropped + step('limit.simplify', sinOverX, parse('1')), // real transform → kept + ]); + + expect(curated.map((s) => s.ruleId)).toEqual(['limit.simplify']); + expect(curated[0]?.after).toEqual(parse('1')); + }); + + it('keeps narrative steps even when before ≡ after', () => { + const curated = curateTrace([ + step('equation.start'), + step('equation.classify.linear'), + step('limit.setup'), + step('limit.indeterminate'), + ]); + + expect(curated).toHaveLength(4); + }); + + it('merges consecutive limit.simplify runs keeping first.before / last.after', () => { + const a = parse('(x^2 - 1)/(x - 1)'); + const b = parse('(x + 1) * (x - 1)/(x - 1)'); + const c = parse('x + 1'); + + const curated = curateTrace([ + step('limit.setup'), + step('limit.simplify', a, b), + step('limit.simplify', b, c), + step('limit.direct', c, two), + ]); + + expect(curated.map((s) => s.ruleId)).toEqual(['limit.setup', 'limit.simplify', 'limit.direct']); + const merged = curated[1]; + expect(merged?.before).toEqual(a); + expect(merged?.after).toEqual(c); + }); + + it('does not merge non-consecutive runs', () => { + const a = parse('x + 1'); + const b = parse('x + 2'); + const curated = curateTrace([ + step('limit.simplify', a, b), + step('limit.indeterminate'), + step('limit.simplify', b, a), + ]); + expect(curated.map((s) => s.ruleId)).toEqual([ + 'limit.simplify', + 'limit.indeterminate', + 'limit.simplify', + ]); + }); +}); + +describe('DISPLAY_RULES', () => { + it('contains every display-grade ruleId emitted by the solver and limits paths', () => { + const displayGrade: TraceRuleId[] = [ + 'equation.start', + 'equation.moveTermsLeft', + 'equation.classify.linear', + 'equation.classify.quadratic', + 'equation.classify.cubic', + 'equation.classify.rational', + 'equation.classify.trigonometric', + 'equation.classify.exponential', + 'equation.classify.logarithmic', + 'equation.classify.transcendental', + 'equation.classify.higherPolynomial', + 'linear.coefficients', + 'linear.isolate', + 'linear.divide', + 'linear.noSolution', + 'linear.allReals', + 'quadratic.coefficients', + 'quadratic.factor', + 'quadratic.zeroProduct', + 'quadratic.solveFactors', + 'quadratic.repeatedRoot', + 'quadratic.formula', + 'quadratic.evaluateRoots', + 'quadratic.complexRoots', + 'cubic.coefficients', + 'cubic.depress', + 'cubic.discriminant', + 'cubic.roots', + 'trig.identify', + 'trig.isolate', + 'trig.inverse', + 'trig.principal', + 'rational.domain', + 'rational.multiplyLcd', + 'rational.checkExtraneous', + 'rational.extraneousExcluded', + 'numeric.newton', + 'numeric.converged', + 'numeric.failed', + 'answer.single', + 'answer.multiple', + 'answer.none', + 'limit.setup', + 'limit.pattern', + 'limit.direct', + 'limit.indeterminate', + 'limit.simplify', + 'limit.lhopital', + 'limit.lhopitalDifferentiate', + 'limit.lhopitalResult', + 'limit.series', + 'limit.seriesResult', + 'limit.numerical', + 'limit.numericalResult', + 'limit.value', + 'limit.infinite', + 'limit.dne', + ]; + + for (const ruleId of displayGrade) { + expect(DISPLAY_RULES.has(ruleId), `${ruleId} should be display-grade`).toBe(true); + } + }); + + it('excludes internal bookkeeping rules', () => { + expect(DISPLAY_RULES.has('limit.lhopitalIterationCheck')).toBe(false); + expect(DISPLAY_RULES.has('limit.seriesTermProbe')).toBe(false); + expect(DISPLAY_RULES.has('simplify.rewrite')).toBe(false); + }); + + it('is derived from RULE_IDS (no orphan entries)', () => { + for (const ruleId of DISPLAY_RULES) { + expect(RULE_IDS).toContain(ruleId); + } + }); +}); + +describe('formatTraceNumber', () => { + it('formats compactly without trailing zeros', () => { + expect(formatTraceNumber(2)).toBe('2'); + expect(formatTraceNumber(2.5)).toBe('2.5'); + expect(formatTraceNumber(1 / 3)).toBe('0.333333'); + expect(formatTraceNumber(-0.5)).toBe('-0.5'); + }); + + it('handles non-finite values without LaTeX or braces', () => { + expect(formatTraceNumber(Number.POSITIVE_INFINITY)).toBe('∞'); + expect(formatTraceNumber(Number.NEGATIVE_INFINITY)).toBe('-∞'); + expect(formatTraceNumber(Number.NaN)).toBe('NaN'); + }); +}); diff --git a/packages/math-engine/src/trace/step-trace.ts b/packages/math-engine/src/trace/step-trace.ts new file mode 100644 index 00000000..0ac411b7 --- /dev/null +++ b/packages/math-engine/src/trace/step-trace.ts @@ -0,0 +1,208 @@ +/** + * StepTrace — structured, opt-in tracing for solver / CAS paths. + * + * A `TraceCollector` is passed explicitly (never ambiently) into engine + * entry points that support tracing. Instrumented code emits via + * `trace?.emit(...)`, so untraced hot paths pay exactly one undefined + * check per emission site and allocate nothing. + * + * Traces are raw by design: they include internal bookkeeping rules + * (per-iteration L'Hôpital checks, series term probes, no-op rewrites). + * `curateTrace()` reduces a raw trace to textbook-grade steps: + * 1. keep only whitelisted display rules, + * 2. drop no-op transformations (before ≡ after), + * 3. merge consecutive runs of the same simplification rule. + */ + +import type { ExpressionNode } from '../parser/ast'; +import { astEquals } from '../symbolic/simplify'; + +/** + * Every rule id the engine can emit. Dot-namespaced; the segments double + * as the i18n key path (`solver.stepRules..{title,detail}`). + * + * Single source of truth: `TraceRuleId`, `DISPLAY_RULES` and the web + * localization keys are all derived from this list. + */ +export const RULE_IDS = [ + // — equation narrative — + 'equation.start', + 'equation.moveTermsLeft', + 'equation.classify.linear', + 'equation.classify.quadratic', + 'equation.classify.cubic', + 'equation.classify.rational', + 'equation.classify.trigonometric', + 'equation.classify.exponential', + 'equation.classify.logarithmic', + 'equation.classify.transcendental', + 'equation.classify.higherPolynomial', + // — linear equations — + 'linear.coefficients', + 'linear.isolate', + 'linear.divide', + 'linear.noSolution', + 'linear.allReals', + // — quadratic equations — + 'quadratic.coefficients', + 'quadratic.factor', + 'quadratic.zeroProduct', + 'quadratic.solveFactors', + 'quadratic.repeatedRoot', + 'quadratic.formula', + 'quadratic.evaluateRoots', + 'quadratic.complexRoots', + // — cubic equations — + 'cubic.coefficients', + 'cubic.depress', + 'cubic.discriminant', + 'cubic.roots', + // — trigonometric equations — + 'trig.identify', + 'trig.isolate', + 'trig.inverse', + 'trig.principal', + // — rational equations — + 'rational.domain', + 'rational.multiplyLcd', + 'rational.checkExtraneous', + 'rational.extraneousExcluded', + // — numerical solving — + 'numeric.newton', + 'numeric.converged', + 'numeric.failed', + // — final answers — + 'answer.single', + 'answer.multiple', + 'answer.none', + // — limits — + 'limit.setup', + 'limit.pattern', + 'limit.direct', + 'limit.indeterminate', + 'limit.simplify', + 'limit.lhopital', + 'limit.lhopitalDifferentiate', + 'limit.lhopitalResult', + 'limit.series', + 'limit.seriesResult', + 'limit.numerical', + 'limit.numericalResult', + 'limit.value', + 'limit.infinite', + 'limit.dne', + // — internal bookkeeping (never displayed; filtered by curateTrace) — + 'limit.lhopitalIterationCheck', + 'limit.seriesTermProbe', + 'simplify.rewrite', +] as const; + +/** String-literal union of every emittable rule id. */ +export type TraceRuleId = (typeof RULE_IDS)[number]; + +/** + * Plain-value parameters attached to a trace step. + * + * Values MUST be pre-formatted plain strings or numbers (formatDecimal / + * astToString output) — never LaTeX. Braces and backslashes break ICU + * MessageFormat interpolation on the web layer. + */ +export type TraceParams = Readonly>; + +/** One recorded transformation: `before` —(ruleId, params)→ `after`. */ +export interface TraceStep { + readonly ruleId: TraceRuleId; + readonly params: TraceParams; + readonly before: ExpressionNode; + readonly after: ExpressionNode; +} + +/** + * Accumulates trace steps. Construct one and pass it via the `trace` + * option of a traceable entry point (`solve`, `limit`, `limitWithSteps`). + */ +export class TraceCollector { + readonly steps: TraceStep[] = []; + + emit( + ruleId: TraceRuleId, + before: ExpressionNode, + after: ExpressionNode, + params: TraceParams = {}, + ): void { + this.steps.push({ ruleId, params, before, after }); + } +} + +/** Internal bookkeeping rules — never textbook material. */ +const INTERNAL_RULES: ReadonlySet = new Set([ + 'limit.lhopitalIterationCheck', + 'limit.seriesTermProbe', + 'simplify.rewrite', +]); + +/** + * Whitelist of textbook-grade rules. Everything the engine emits that is + * NOT in this set is internal noise and removed by `curateTrace()`. + */ +export const DISPLAY_RULES: ReadonlySet = new Set( + RULE_IDS.filter((id) => !INTERNAL_RULES.has(id)), +); + +/** + * Rules whose entire purpose is an AST transformation. A step with one of + * these ids that did not change the AST (before ≡ after) is a no-op and is + * dropped. All other display rules are narrative (announcements, classifications, + * parameter extraction) and legitimately carry `before === after`. + */ +const TRANSFORM_RULES: ReadonlySet = new Set([ + 'equation.moveTermsLeft', + 'linear.divide', + 'rational.multiplyLcd', + 'limit.simplify', + 'limit.lhopitalDifferentiate', + 'simplify.rewrite', +]); + +/** Consecutive runs of these rules collapse into a single merged step. */ +const MERGEABLE_RULES: ReadonlySet = new Set([ + 'limit.simplify', + 'simplify.rewrite', +]); + +/** + * Reduce a raw trace to textbook-shaped steps: + * whitelist-filter → no-op drop → consecutive-simplify merge. + * + * Merging keeps the first step's `before` and the last step's `after` + * (params of the first step win — they are informational only for + * simplification runs). + */ +export function curateTrace(steps: readonly TraceStep[]): TraceStep[] { + const curated: TraceStep[] = []; + + for (const step of steps) { + if (!DISPLAY_RULES.has(step.ruleId)) continue; + if (TRANSFORM_RULES.has(step.ruleId) && astEquals(step.before, step.after)) continue; + + const previous = curated[curated.length - 1]; + if (previous && MERGEABLE_RULES.has(step.ruleId) && previous.ruleId === step.ruleId) { + curated[curated.length - 1] = { ...previous, after: step.after }; + continue; + } + + curated.push(step); + } + + return curated; +} + +/** + * Compact decimal formatting for trace params — no trailing zeros, never + * LaTeX. Shared by the solver and limits emitters. + */ +export function formatTraceNumber(n: number): string { + if (!Number.isFinite(n)) return n > 0 ? '∞' : n < 0 ? '-∞' : 'NaN'; + if (Number.isInteger(n)) return String(n); + return n.toFixed(6).replace(/\.?0+$/, ''); +} From 900c86db64692c1e225607c970fe184d2a17b5ea Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:56:53 -0500 Subject: [PATCH 2/4] feat(web): i18n keys for solver StepTrace rule text (8 locales) Adds solver.stepRules..{title,detail}, solver.limitTab.*, and solver.stepCategories.* to all 8 locale files, covering every display ruleId the math-engine's curateTrace() emits (equation/linear/quadratic/ cubic/trig/rational/numeric/answer/limit) plus the Limit tab's UI copy and step-category badge labels. Genuine per-language math terminology, not English placeholders; ICU placeholder names match the engine's emitted param names exactly, verified end to end against a real ICU MessageFormat parser across all 8 locales (including the direction select block in limit.setup). Co-Authored-By: Claude Fable 5 --- apps/web/messages/de.json | 277 ++++++++++++++++++++++++++++++++++++++ apps/web/messages/en.json | 277 ++++++++++++++++++++++++++++++++++++++ apps/web/messages/es.json | 277 ++++++++++++++++++++++++++++++++++++++ apps/web/messages/fr.json | 277 ++++++++++++++++++++++++++++++++++++++ apps/web/messages/ja.json | 277 ++++++++++++++++++++++++++++++++++++++ apps/web/messages/ru.json | 277 ++++++++++++++++++++++++++++++++++++++ apps/web/messages/uk.json | 277 ++++++++++++++++++++++++++++++++++++++ apps/web/messages/zh.json | 277 ++++++++++++++++++++++++++++++++++++++ 8 files changed, 2216 insertions(+) diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 605d49ee..c827c05c 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -976,6 +976,283 @@ "directionField": "Direction Field", "csvExport": "CSV Export", "aboutTitle": "About Numerical ODE Methods" + }, + "stepRules": { + "equation": { + "start": { + "title": "Gleichung aufstellen", + "detail": "Notiere die ursprüngliche Gleichung und bestimme die zu lösende Variable." + }, + "moveTermsLeft": { + "title": "Alle Terme auf eine Seite bringen", + "detail": "Subtrahiere {rhs} von beiden Seiten, sodass die rechte Seite null wird." + }, + "classify": { + "linear": { + "title": "Als lineare Gleichung einordnen", + "detail": "Die Gleichung hat den Grad 1 und lässt sich durch direktes Isolieren der Variablen lösen." + }, + "quadratic": { + "title": "Als quadratische Gleichung einordnen", + "detail": "Die Gleichung hat den Grad 2; Faktorisieren oder die Mitternachtsformel liefern die Lösungen." + }, + "cubic": { + "title": "Als kubische Gleichung einordnen", + "detail": "Die Gleichung hat den Grad 3; eine Substitution zur reduzierten Kubik und die Cardano-Formel führen zur Lösung." + }, + "rational": { + "title": "Als gebrochenrationale Gleichung einordnen", + "detail": "Die Gleichung enthält die Variable im Nenner, daher müssen zuerst die Brüche beseitigt werden." + }, + "trigonometric": { + "title": "Als trigonometrische Gleichung einordnen", + "detail": "Die Gleichung enthält eine trigonometrische Funktion; ihre Umkehrfunktion isoliert die Variable." + }, + "exponential": { + "title": "Als Exponentialgleichung einordnen", + "detail": "Die Variable steht im Exponenten; Logarithmen oder numerische Verfahren werden zur Lösung benötigt." + }, + "logarithmic": { + "title": "Als logarithmische Gleichung einordnen", + "detail": "Die Gleichung enthält einen Logarithmus der Variablen; Exponenzieren beider Seiten isoliert sie." + }, + "transcendental": { + "title": "Als transzendente Gleichung einordnen", + "detail": "Die Gleichung mischt algebraische und transzendente Terme und wird daher numerisch gelöst." + }, + "higherPolynomial": { + "title": "Als Polynom höheren Grades einordnen", + "detail": "Die Gleichung hat Grad 4 oder höher; anstelle einer geschlossenen Formel wird numerische Nullstellensuche verwendet." + } + } + }, + "linear": { + "coefficients": { + "title": "Lineare Koeffizienten bestimmen", + "detail": "Schreibe die Gleichung als a·{variable} + b = 0; dabei ist a = {a} und b = {b}." + }, + "isolate": { + "title": "Variablenterm isolieren", + "detail": "Subtrahiere den konstanten Term von beiden Seiten, sodass {a}{variable} = {negB} entsteht." + }, + "divide": { + "title": "Durch den Koeffizienten dividieren", + "detail": "Division beider Seiten durch {a} isoliert {variable}, also {variable} = {solution}." + }, + "noSolution": { + "title": "Keine Lösung feststellen", + "detail": "Der Koeffizient der Variablen ist null, die Konstante {b} jedoch nicht, sodass kein Wert die Gleichung erfüllt." + }, + "allReals": { + "title": "Alle reellen Zahlen als Lösung feststellen", + "detail": "Beide Koeffizienten sind null, sodass jede reelle Zahl die Gleichung erfüllt." + } + }, + "quadratic": { + "coefficients": { + "title": "Quadratische Koeffizienten bestimmen", + "detail": "Schreibe die Gleichung in Normalform a{variable}² + b{variable} + c = 0; dabei ist a = {a}, b = {b}, c = {c}." + }, + "factor": { + "title": "Quadratischen Ausdruck faktorisieren", + "detail": "Finde zwei Zahlen mit Produkt {ac} und Summe {b}; das ergibt die Nullstellen {r1} und {r2}." + }, + "zeroProduct": { + "title": "Nullprodukt-Eigenschaft anwenden", + "detail": "Da das faktorisierte Produkt null ergibt, muss mindestens ein Faktor verschwinden: {variable} = {r1} oder {variable} = {r2}." + }, + "solveFactors": { + "title": "Jeden Faktor lösen", + "detail": "Setzt man jeden Faktor gleich null, ergeben sich die Lösungen {variable} = {r1} und {variable} = {r2}." + }, + "repeatedRoot": { + "title": "Doppelte Nullstelle bestimmen", + "detail": "Die Diskriminante ist null, sodass beide Nullstellen bei {variable} = {r} mit Vielfachheit 2 zusammenfallen." + }, + "formula": { + "title": "Mitternachtsformel anwenden", + "detail": "Mit a = {a}, b = {b}, c = {c} ergibt die Diskriminante b² − 4ac den Wert {discriminant}." + }, + "evaluateRoots": { + "title": "Nullstellen berechnen", + "detail": "Einsetzen in die Mitternachtsformel ergibt {variable}₁ = {r1} und {variable}₂ = {r2}." + }, + "complexRoots": { + "title": "Komplexe Nullstellen berechnen", + "detail": "Die Diskriminante {discriminant} ist negativ, was die komplex konjugierten Nullstellen {variable} = {realPart} ± {imagPart}i ergibt." + } + }, + "cubic": { + "coefficients": { + "title": "Kubische Koeffizienten bestimmen", + "detail": "Schreibe die Gleichung in Normalform a{variable}³ + b{variable}² + c{variable} + d = 0; dabei ist a = {a}, b = {b}, c = {c}, d = {d}." + }, + "depress": { + "title": "Kubik reduzieren", + "detail": "Eine Verschiebung der Variablen um {shift} entfernt den quadratischen Term und ergibt t³ + {p}t + {q} = 0." + }, + "discriminant": { + "title": "Diskriminante der Kubik berechnen", + "detail": "Die Diskriminante Δ = −4p³ − 27q² beträgt {delta} und bestimmt, wie viele reelle Nullstellen existieren." + }, + "roots": { + "title": "Nullstellen der Kubik berechnen", + "detail": "Die Anwendung der Cardano-Formel mit Rücksubstitution ergibt die Nullstellen {roots} für {variable}." + } + }, + "trig": { + "identify": { + "title": "Trigonometrische Funktion bestimmen", + "detail": "Die Gleichung enthält {fn}({variable}); das Isolieren dieses Terms ist der erste Schritt." + }, + "isolate": { + "title": "Trigonometrischen Ausdruck isolieren", + "detail": "Ordne die Gleichung so um, dass {fn}({variable}) allein einer Konstanten entspricht." + }, + "inverse": { + "title": "Umkehrfunktion anwenden", + "detail": "Die Umkehrfunktion von {fn} auf beide Seiten angewendet liefert die Hauptlösung für {variable} sowie ihre periodische Familie." + }, + "principal": { + "title": "Hauptlösung berechnen", + "detail": "Der numerisch gefundene Hauptwert ist {variable} = {solutions}." + } + }, + "rational": { + "domain": { + "title": "Definitionsbereich einschränken", + "detail": "Jeder Nenner mit {variable} darf nicht null sein: {denominators} ≠ 0." + }, + "multiplyLcd": { + "title": "Mit dem Hauptnenner multiplizieren", + "detail": "Die Multiplikation beider Seiten mit {lcd} beseitigt alle Brüche und ergibt eine polynomiale Gleichung." + }, + "checkExtraneous": { + "title": "Kandidaten gegen den Definitionsbereich prüfen", + "detail": "Nach Ausschluss unzulässiger Wurzeln erfüllen {kept} Kandidatenlösung(en) die Definitionsbereich-Einschränkungen." + }, + "extraneousExcluded": { + "title": "Unzulässige Wurzel ausschließen", + "detail": "Das Einsetzen von {variable} = {root} macht einen Nenner null, daher wird sie als unzulässig verworfen." + } + }, + "numeric": { + "newton": { + "title": "Newton-Raphson-Verfahren anwenden", + "detail": "Diese Gleichung hat keine geschlossene Lösung, sodass {variable} ausgehend von einem Startwert iterativ verfeinert wird." + }, + "converged": { + "title": "Zur Lösung konvergieren", + "detail": "Die Iteration konvergierte nach {iterations} Schritten und ergab {solutions}." + }, + "failed": { + "title": "Keine Konvergenz feststellen", + "detail": "Das Newton-Raphson-Verfahren konvergierte ausgehend vom Standard-Startwert nicht." + } + }, + "answer": { + "single": { + "title": "Endgültige Lösung angeben", + "detail": "Die Gleichung hat genau eine Lösung: {variable} = {solution}." + }, + "multiple": { + "title": "Endgültige Lösungen angeben", + "detail": "Die Gleichung hat {count} Lösungen: {variable} = {solutions}." + }, + "none": { + "title": "Keine reellen Lösungen feststellen", + "detail": "Kein reeller Wert der Variablen erfüllt die Gleichung." + } + }, + "limit": { + "setup": { + "title": "Grenzwert aufstellen", + "detail": "Berechne den Grenzwert des Ausdrucks, wenn {variable} gegen {point} strebt{direction, select, left { von links} right { von rechts} other {}}." + }, + "pattern": { + "title": "Standardgrenzwert erkennen", + "detail": "Dies ist der bekannte Grenzwert {pattern}, der direkt {value} ergibt." + }, + "direct": { + "title": "Punkt direkt einsetzen", + "detail": "Der Ausdruck ist an dieser Stelle stetig, sodass der Grenzwert dem Funktionswert {value} entspricht." + }, + "indeterminate": { + "title": "Unbestimmten Ausdruck erkennen", + "detail": "Direktes Einsetzen ergibt den unbestimmten Ausdruck {form}, sodass eine weitere Analyse nötig ist." + }, + "simplify": { + "title": "Ausdruck vereinfachen", + "detail": "Algebraisches Vereinfachen liefert einen äquivalenten Ausdruck, dessen Grenzwert leichter zu bestimmen ist." + }, + "lhopital": { + "title": "Regel von de l'Hôpital anwenden", + "detail": "Da der Grenzwert die unbestimmte Form {form} hat, entspricht der Grenzwert des Quotienten dem Grenzwert des Quotienten der Ableitungen." + }, + "lhopitalDifferentiate": { + "title": "Zähler und Nenner ableiten", + "detail": "Leite Zähler und Nenner getrennt ab (Durchlauf {iteration}) und untersuche dann den Grenzwert des neuen Quotienten." + }, + "lhopitalResult": { + "title": "Nach der Regel von de l'Hôpital auswerten", + "detail": "Das Einsetzen in den abgeleiteten Quotienten ergibt {value}." + }, + "series": { + "title": "Als Taylorreihe entwickeln", + "detail": "Der führende Term des Zählers hat die Ordnung {numPower}, der des Nenners die Ordnung {denPower}; ihr Vergleich bestimmt den Grenzwert." + }, + "seriesResult": { + "title": "Reihenvergleich auswerten", + "detail": "Das Verhältnis der führenden Reihenkoeffizienten ergibt {value}." + }, + "numerical": { + "title": "Numerisch annähern", + "detail": "Symbolische Verfahren blieben ergebnislos, daher wird der Ausdruck an Punkten nahe dem Grenzwert ausgewertet, um Konvergenz festzustellen." + }, + "numericalResult": { + "title": "Numerische Konvergenz bestätigen", + "detail": "Die abgetasteten Werte konvergieren gegen {value}." + }, + "value": { + "title": "Wert des Grenzwerts angeben", + "detail": "Zusammengefasst ergibt der Grenzwert {value}." + }, + "infinite": { + "title": "Unendlichen Grenzwert feststellen", + "detail": "Der Ausdruck wächst unbeschränkt: Der Grenzwert ist {sign}." + }, + "dne": { + "title": "Feststellen, dass der Grenzwert nicht existiert", + "detail": "Weder ein endlicher noch ein unendlicher Grenzwert konnte ermittelt werden, der Grenzwert existiert also nicht." + } + } + }, + "limitTab": { + "label": "Grenzwert", + "shortLabel": "Lim", + "approach": "Grenzpunkt", + "pointPlaceholder": "z. B. 0, inf, -inf", + "direction": "Richtung", + "directionBoth": "Beidseitig", + "directionLeft": "Von links", + "directionRight": "Von rechts", + "invalidPoint": "Gib einen gültigen Grenzpunkt ein: eine Zahl, inf oder -inf." + }, + "stepCategories": { + "identification": "Identifizieren", + "simplification": "Vereinfachen", + "differentiation": "Ableiten", + "integration": "Integrieren", + "rearrangement": "Umstellen", + "isolation": "Isolieren", + "factorization": "Faktorisieren", + "formula": "Formel", + "substitution": "Substituieren", + "expansion": "Ausmultiplizieren", + "identity": "Identität", + "finalAnswer": "Antwort", + "evaluation": "Auswerten", + "limit": "Grenzwert" } }, "complex": { diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a665df56..5b9e6c87 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1010,6 +1010,283 @@ "directionField": "Direction Field", "csvExport": "CSV Export", "aboutTitle": "About Numerical ODE Methods" + }, + "stepRules": { + "equation": { + "start": { + "title": "State the equation", + "detail": "Write down the original equation and identify the variable to solve for." + }, + "moveTermsLeft": { + "title": "Move all terms to one side", + "detail": "Subtract {rhs} from both sides so the right-hand side becomes zero." + }, + "classify": { + "linear": { + "title": "Classify as a linear equation", + "detail": "The equation has degree 1, so it can be solved by isolating the variable directly." + }, + "quadratic": { + "title": "Classify as a quadratic equation", + "detail": "The equation has degree 2, so factoring or the quadratic formula will find its roots." + }, + "cubic": { + "title": "Classify as a cubic equation", + "detail": "The equation has degree 3, so a depressed-cubic substitution and Cardano's formula apply." + }, + "rational": { + "title": "Classify as a rational equation", + "detail": "The equation contains the variable in a denominator, so clearing fractions is required before solving." + }, + "trigonometric": { + "title": "Classify as a trigonometric equation", + "detail": "The equation involves a trigonometric function, so an inverse trig function will isolate the variable." + }, + "exponential": { + "title": "Classify as an exponential equation", + "detail": "The variable appears in an exponent, so logarithms or numerical methods are needed to solve it." + }, + "logarithmic": { + "title": "Classify as a logarithmic equation", + "detail": "The equation contains a logarithm of the variable, so exponentiating both sides will isolate it." + }, + "transcendental": { + "title": "Classify as a transcendental equation", + "detail": "The equation mixes algebraic and transcendental terms, so it is solved numerically." + }, + "higherPolynomial": { + "title": "Classify as a higher-degree polynomial", + "detail": "The equation has degree 4 or higher, so numerical root-finding is used instead of a closed-form formula." + } + } + }, + "linear": { + "coefficients": { + "title": "Identify the linear coefficients", + "detail": "Write the equation in the form a·{variable} + b = 0, giving a = {a} and b = {b}." + }, + "isolate": { + "title": "Isolate the variable term", + "detail": "Subtract the constant term from both sides to get {a}{variable} = {negB}." + }, + "divide": { + "title": "Divide by the coefficient", + "detail": "Dividing both sides by {a} isolates {variable}, giving {variable} = {solution}." + }, + "noSolution": { + "title": "Report no solution", + "detail": "The variable's coefficient is zero while the constant {b} is not, so no value satisfies the equation." + }, + "allReals": { + "title": "Report all reals as solutions", + "detail": "Both coefficients reduce to zero, so every real number satisfies the equation." + } + }, + "quadratic": { + "coefficients": { + "title": "Identify the quadratic coefficients", + "detail": "Write the equation in standard form a{variable}² + b{variable} + c = 0, giving a = {a}, b = {b}, c = {c}." + }, + "factor": { + "title": "Factor the quadratic expression", + "detail": "Find two numbers whose product is {ac} and whose sum is {b}, giving the roots {r1} and {r2}." + }, + "zeroProduct": { + "title": "Apply the zero-product property", + "detail": "Since the factored product equals zero, at least one factor must vanish: {variable} = {r1} or {variable} = {r2}." + }, + "solveFactors": { + "title": "Solve each factor", + "detail": "Setting each factor to zero gives the two solutions {variable} = {r1} and {variable} = {r2}." + }, + "repeatedRoot": { + "title": "Identify the repeated root", + "detail": "The discriminant is zero, so both roots coincide at {variable} = {r} with multiplicity 2." + }, + "formula": { + "title": "Apply the quadratic formula", + "detail": "With a = {a}, b = {b}, c = {c}, the discriminant b² − 4ac equals {discriminant}." + }, + "evaluateRoots": { + "title": "Evaluate the roots", + "detail": "Substituting into the quadratic formula gives {variable}₁ = {r1} and {variable}₂ = {r2}." + }, + "complexRoots": { + "title": "Compute the complex roots", + "detail": "The discriminant {discriminant} is negative, giving complex conjugate roots {variable} = {realPart} ± {imagPart}i." + } + }, + "cubic": { + "coefficients": { + "title": "Identify the cubic coefficients", + "detail": "Write the equation in standard form a{variable}³ + b{variable}² + c{variable} + d = 0, giving a = {a}, b = {b}, c = {c}, d = {d}." + }, + "depress": { + "title": "Depress the cubic", + "detail": "Shifting the variable by {shift} removes the quadratic term, giving t³ + {p}t + {q} = 0." + }, + "discriminant": { + "title": "Compute the cubic discriminant", + "detail": "The discriminant Δ = −4p³ − 27q² equals {delta}, determining how many real roots exist." + }, + "roots": { + "title": "Compute the cubic roots", + "detail": "Applying Cardano's formula and back-substituting gives the roots {roots} for {variable}." + } + }, + "trig": { + "identify": { + "title": "Identify the trigonometric function", + "detail": "The equation involves {fn}({variable}), so isolating this term is the first step." + }, + "isolate": { + "title": "Isolate the trigonometric expression", + "detail": "Rearrange the equation so that {fn}({variable}) equals a constant on its own." + }, + "inverse": { + "title": "Apply the inverse trigonometric function", + "detail": "Applying the inverse of {fn} to both sides gives the principal solution for {variable}, plus its periodic family." + }, + "principal": { + "title": "Compute the principal solution", + "detail": "The principal value found numerically is {variable} = {solutions}." + } + }, + "rational": { + "domain": { + "title": "State the domain restrictions", + "detail": "Every denominator containing {variable} must be non-zero: {denominators} ≠ 0." + }, + "multiplyLcd": { + "title": "Multiply by the least common denominator", + "detail": "Multiplying both sides by {lcd} clears all fractions, leaving a polynomial equation." + }, + "checkExtraneous": { + "title": "Check candidates against the domain", + "detail": "{kept} candidate solution(s) satisfy the domain restrictions after excluding any extraneous roots." + }, + "extraneousExcluded": { + "title": "Exclude an extraneous root", + "detail": "Substituting {variable} = {root} makes a denominator zero, so it is rejected as extraneous." + } + }, + "numeric": { + "newton": { + "title": "Apply Newton-Raphson iteration", + "detail": "This equation has no closed-form solution, so {variable} is refined iteratively from an initial guess." + }, + "converged": { + "title": "Converge to a solution", + "detail": "The iteration converged after {iterations} steps, yielding {solutions}." + }, + "failed": { + "title": "Report non-convergence", + "detail": "The Newton-Raphson iteration failed to converge from the default initial guess." + } + }, + "answer": { + "single": { + "title": "State the final answer", + "detail": "The equation has exactly one solution: {variable} = {solution}." + }, + "multiple": { + "title": "State the final answers", + "detail": "The equation has {count} solutions: {variable} = {solutions}." + }, + "none": { + "title": "Report no real solutions", + "detail": "No real value of the variable satisfies the equation." + } + }, + "limit": { + "setup": { + "title": "Set up the limit", + "detail": "Evaluate the limit of the expression as {variable} approaches {point}{direction, select, left { from the left} right { from the right} other {}}." + }, + "pattern": { + "title": "Recognize a standard limit", + "detail": "This is the well-known limit {pattern}, which evaluates directly to {value}." + }, + "direct": { + "title": "Substitute the point directly", + "detail": "The expression is continuous at this point, so the limit equals the function value {value}." + }, + "indeterminate": { + "title": "Identify the indeterminate form", + "detail": "Direct substitution produces the indeterminate form {form}, so further analysis is required." + }, + "simplify": { + "title": "Simplify the expression", + "detail": "Algebraic simplification produces an equivalent expression whose limit is easier to evaluate." + }, + "lhopital": { + "title": "Apply L'Hôpital's rule", + "detail": "Since the limit has the indeterminate form {form}, the limit of the ratio equals the limit of the ratio of derivatives." + }, + "lhopitalDifferentiate": { + "title": "Differentiate numerator and denominator", + "detail": "Differentiate the numerator and denominator separately (iteration {iteration}), then examine the new quotient's limit." + }, + "lhopitalResult": { + "title": "Evaluate after L'Hôpital's rule", + "detail": "Substituting into the differentiated quotient gives {value}." + }, + "series": { + "title": "Expand as a Taylor series", + "detail": "The numerator's leading term has order {numPower} and the denominator's has order {denPower}; comparing them determines the limit." + }, + "seriesResult": { + "title": "Evaluate the series comparison", + "detail": "The ratio of the leading series coefficients gives {value}." + }, + "numerical": { + "title": "Approximate numerically", + "detail": "Symbolic methods were inconclusive, so the expression is sampled at points approaching the limit to detect convergence." + }, + "numericalResult": { + "title": "Confirm numerical convergence", + "detail": "The sampled values converge to {value}." + }, + "value": { + "title": "State the limit's value", + "detail": "Combining the steps above, the limit evaluates to {value}." + }, + "infinite": { + "title": "State that the limit is infinite", + "detail": "The expression grows without bound: the limit is {sign}." + }, + "dne": { + "title": "State that the limit does not exist", + "detail": "No finite or infinite limit could be established, so the limit does not exist." + } + } + }, + "limitTab": { + "label": "Limit", + "shortLabel": "Lim", + "approach": "Approach point", + "pointPlaceholder": "e.g. 0, inf, -inf", + "direction": "Direction", + "directionBoth": "Both sides", + "directionLeft": "From the left", + "directionRight": "From the right", + "invalidPoint": "Enter a valid approach point: a number, inf, or -inf." + }, + "stepCategories": { + "identification": "Identify", + "simplification": "Simplify", + "differentiation": "Differentiate", + "integration": "Integrate", + "rearrangement": "Rearrange", + "isolation": "Isolate", + "factorization": "Factor", + "formula": "Formula", + "substitution": "Substitute", + "expansion": "Expand", + "identity": "Identity", + "finalAnswer": "Answer", + "evaluation": "Evaluate", + "limit": "Limit" } }, "complex": { diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 9bad412f..2cd095c0 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -976,6 +976,283 @@ "directionField": "Campo de dirección", "csvExport": "Exportar CSV", "aboutTitle": "Acerca de los métodos numéricos para EDO" + }, + "stepRules": { + "equation": { + "start": { + "title": "Plantear la ecuación", + "detail": "Escribe la ecuación original e identifica la variable que se debe despejar." + }, + "moveTermsLeft": { + "title": "Pasar todos los términos a un lado", + "detail": "Resta {rhs} de ambos lados para que el lado derecho sea cero." + }, + "classify": { + "linear": { + "title": "Clasificar como ecuación lineal", + "detail": "La ecuación es de grado 1, por lo que se resuelve aislando la variable directamente." + }, + "quadratic": { + "title": "Clasificar como ecuación cuadrática", + "detail": "La ecuación es de grado 2, por lo que factorizar o la fórmula general hallarán sus raíces." + }, + "cubic": { + "title": "Clasificar como ecuación cúbica", + "detail": "La ecuación es de grado 3, por lo que se aplica una sustitución a la cúbica deprimida y la fórmula de Cardano." + }, + "rational": { + "title": "Clasificar como ecuación racional", + "detail": "La ecuación tiene la variable en un denominador, por lo que hay que eliminar las fracciones antes de resolver." + }, + "trigonometric": { + "title": "Clasificar como ecuación trigonométrica", + "detail": "La ecuación involucra una función trigonométrica, así que su función inversa aislará la variable." + }, + "exponential": { + "title": "Clasificar como ecuación exponencial", + "detail": "La variable aparece en un exponente, por lo que se necesitan logaritmos o métodos numéricos para resolverla." + }, + "logarithmic": { + "title": "Clasificar como ecuación logarítmica", + "detail": "La ecuación contiene un logaritmo de la variable, por lo que exponenciar ambos lados la aislará." + }, + "transcendental": { + "title": "Clasificar como ecuación trascendente", + "detail": "La ecuación combina términos algebraicos y trascendentes, por lo que se resuelve numéricamente." + }, + "higherPolynomial": { + "title": "Clasificar como polinomio de grado superior", + "detail": "La ecuación es de grado 4 o mayor, así que se usa búsqueda numérica de raíces en lugar de una fórmula cerrada." + } + } + }, + "linear": { + "coefficients": { + "title": "Identificar los coeficientes lineales", + "detail": "Escribe la ecuación como a·{variable} + b = 0, con a = {a} y b = {b}." + }, + "isolate": { + "title": "Aislar el término variable", + "detail": "Resta el término constante de ambos lados para obtener {a}{variable} = {negB}." + }, + "divide": { + "title": "Dividir entre el coeficiente", + "detail": "Dividir ambos lados entre {a} aísla {variable}, dando {variable} = {solution}." + }, + "noSolution": { + "title": "Indicar que no hay solución", + "detail": "El coeficiente de la variable es cero mientras que la constante {b} no lo es, así que ningún valor satisface la ecuación." + }, + "allReals": { + "title": "Indicar que todos los reales son solución", + "detail": "Ambos coeficientes se reducen a cero, así que cualquier número real satisface la ecuación." + } + }, + "quadratic": { + "coefficients": { + "title": "Identificar los coeficientes cuadráticos", + "detail": "Escribe la ecuación en forma estándar a{variable}² + b{variable} + c = 0, con a = {a}, b = {b}, c = {c}." + }, + "factor": { + "title": "Factorizar la expresión cuadrática", + "detail": "Busca dos números cuyo producto sea {ac} y cuya suma sea {b}, obteniendo las raíces {r1} y {r2}." + }, + "zeroProduct": { + "title": "Aplicar la propiedad del producto nulo", + "detail": "Como el producto factorizado es cero, al menos un factor debe anularse: {variable} = {r1} o {variable} = {r2}." + }, + "solveFactors": { + "title": "Resolver cada factor", + "detail": "Igualando cada factor a cero se obtienen las soluciones {variable} = {r1} y {variable} = {r2}." + }, + "repeatedRoot": { + "title": "Identificar la raíz doble", + "detail": "El discriminante es cero, así que ambas raíces coinciden en {variable} = {r} con multiplicidad 2." + }, + "formula": { + "title": "Aplicar la fórmula general", + "detail": "Con a = {a}, b = {b}, c = {c}, el discriminante b² − 4ac es igual a {discriminant}." + }, + "evaluateRoots": { + "title": "Calcular las raíces", + "detail": "Sustituyendo en la fórmula general se obtiene {variable}₁ = {r1} y {variable}₂ = {r2}." + }, + "complexRoots": { + "title": "Calcular las raíces complejas", + "detail": "El discriminante {discriminant} es negativo, dando las raíces complejas conjugadas {variable} = {realPart} ± {imagPart}i." + } + }, + "cubic": { + "coefficients": { + "title": "Identificar los coeficientes cúbicos", + "detail": "Escribe la ecuación en forma estándar a{variable}³ + b{variable}² + c{variable} + d = 0, con a = {a}, b = {b}, c = {c}, d = {d}." + }, + "depress": { + "title": "Deprimir la cúbica", + "detail": "Desplazar la variable en {shift} elimina el término cuadrático, dando t³ + {p}t + {q} = 0." + }, + "discriminant": { + "title": "Calcular el discriminante cúbico", + "detail": "El discriminante Δ = −4p³ − 27q² es igual a {delta}, lo que determina cuántas raíces reales existen." + }, + "roots": { + "title": "Calcular las raíces de la cúbica", + "detail": "Aplicar la fórmula de Cardano y sustituir de vuelta da las raíces {roots} para {variable}." + } + }, + "trig": { + "identify": { + "title": "Identificar la función trigonométrica", + "detail": "La ecuación involucra {fn}({variable}), así que aislar este término es el primer paso." + }, + "isolate": { + "title": "Aislar la expresión trigonométrica", + "detail": "Reordena la ecuación para que {fn}({variable}) sea igual a una constante por sí sola." + }, + "inverse": { + "title": "Aplicar la función trigonométrica inversa", + "detail": "Aplicar la inversa de {fn} a ambos lados da la solución principal para {variable}, más su familia periódica." + }, + "principal": { + "title": "Calcular la solución principal", + "detail": "El valor principal hallado numéricamente es {variable} = {solutions}." + } + }, + "rational": { + "domain": { + "title": "Indicar las restricciones del dominio", + "detail": "Todo denominador que contenga {variable} debe ser distinto de cero: {denominators} ≠ 0." + }, + "multiplyLcd": { + "title": "Multiplicar por el mínimo común denominador", + "detail": "Multiplicar ambos lados por {lcd} elimina todas las fracciones, dejando una ecuación polinómica." + }, + "checkExtraneous": { + "title": "Verificar candidatos contra el dominio", + "detail": "Tras excluir las raíces extrañas, {kept} solución(es) candidata(s) cumplen las restricciones del dominio." + }, + "extraneousExcluded": { + "title": "Excluir una raíz extraña", + "detail": "Sustituir {variable} = {root} anula un denominador, por lo que se rechaza por ser extraña." + } + }, + "numeric": { + "newton": { + "title": "Aplicar el método de Newton-Raphson", + "detail": "Esta ecuación no tiene solución cerrada, así que {variable} se refina iterativamente a partir de una estimación inicial." + }, + "converged": { + "title": "Converger a una solución", + "detail": "La iteración convergió tras {iterations} pasos, dando {solutions}." + }, + "failed": { + "title": "Indicar la falta de convergencia", + "detail": "La iteración de Newton-Raphson no logró converger desde la estimación inicial predeterminada." + } + }, + "answer": { + "single": { + "title": "Indicar la respuesta final", + "detail": "La ecuación tiene exactamente una solución: {variable} = {solution}." + }, + "multiple": { + "title": "Indicar las respuestas finales", + "detail": "La ecuación tiene {count} soluciones: {variable} = {solutions}." + }, + "none": { + "title": "Indicar que no hay soluciones reales", + "detail": "Ningún valor real de la variable satisface la ecuación." + } + }, + "limit": { + "setup": { + "title": "Plantear el límite", + "detail": "Evalúa el límite de la expresión cuando {variable} tiende a {point}{direction, select, left { por la izquierda} right { por la derecha} other {}}." + }, + "pattern": { + "title": "Reconocer un límite estándar", + "detail": "Este es el límite conocido {pattern}, que se evalúa directamente como {value}." + }, + "direct": { + "title": "Sustituir el punto directamente", + "detail": "La expresión es continua en este punto, así que el límite es igual al valor de la función {value}." + }, + "indeterminate": { + "title": "Identificar la forma indeterminada", + "detail": "La sustitución directa produce la forma indeterminada {form}, por lo que se requiere un análisis adicional." + }, + "simplify": { + "title": "Simplificar la expresión", + "detail": "La simplificación algebraica produce una expresión equivalente cuyo límite es más fácil de evaluar." + }, + "lhopital": { + "title": "Aplicar la regla de L'Hôpital", + "detail": "Como el límite tiene la forma indeterminada {form}, el límite del cociente equivale al límite del cociente de las derivadas." + }, + "lhopitalDifferentiate": { + "title": "Derivar numerador y denominador", + "detail": "Deriva el numerador y el denominador por separado (iteración {iteration}) y examina el límite del nuevo cociente." + }, + "lhopitalResult": { + "title": "Evaluar tras la regla de L'Hôpital", + "detail": "Sustituir en el cociente derivado da {value}." + }, + "series": { + "title": "Desarrollar en serie de Taylor", + "detail": "El término principal del numerador tiene orden {numPower} y el del denominador orden {denPower}; su comparación determina el límite." + }, + "seriesResult": { + "title": "Evaluar la comparación de series", + "detail": "La razón de los coeficientes principales de la serie da {value}." + }, + "numerical": { + "title": "Aproximar numéricamente", + "detail": "Los métodos simbólicos no fueron concluyentes, así que la expresión se evalúa en puntos cercanos al límite para detectar convergencia." + }, + "numericalResult": { + "title": "Confirmar la convergencia numérica", + "detail": "Los valores muestreados convergen a {value}." + }, + "value": { + "title": "Indicar el valor del límite", + "detail": "Combinando los pasos anteriores, el límite es {value}." + }, + "infinite": { + "title": "Indicar que el límite es infinito", + "detail": "La expresión crece sin límite: el límite es {sign}." + }, + "dne": { + "title": "Indicar que el límite no existe", + "detail": "No se pudo establecer ningún límite finito ni infinito, por lo que el límite no existe." + } + } + }, + "limitTab": { + "label": "Límite", + "shortLabel": "Lím", + "approach": "Punto de aproximación", + "pointPlaceholder": "p. ej. 0, inf, -inf", + "direction": "Dirección", + "directionBoth": "Ambos lados", + "directionLeft": "Por la izquierda", + "directionRight": "Por la derecha", + "invalidPoint": "Introduce un punto de aproximación válido: un número, inf o -inf." + }, + "stepCategories": { + "identification": "Identificar", + "simplification": "Simplificar", + "differentiation": "Derivar", + "integration": "Integrar", + "rearrangement": "Reordenar", + "isolation": "Aislar", + "factorization": "Factorizar", + "formula": "Fórmula", + "substitution": "Sustituir", + "expansion": "Expandir", + "identity": "Identidad", + "finalAnswer": "Respuesta", + "evaluation": "Evaluar", + "limit": "Límite" } }, "complex": { diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 07f95f6f..ebb04f45 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1009,6 +1009,283 @@ "directionField": "Champ de direction", "csvExport": "Export CSV", "aboutTitle": "À propos des méthodes numériques pour les EDO" + }, + "stepRules": { + "equation": { + "start": { + "title": "Poser l'équation", + "detail": "Notez l'équation d'origine et identifiez la variable à résoudre." + }, + "moveTermsLeft": { + "title": "Regrouper tous les termes d'un côté", + "detail": "Soustrayez {rhs} des deux côtés afin que le membre de droite devienne nul." + }, + "classify": { + "linear": { + "title": "Classer comme équation linéaire", + "detail": "L'équation est de degré 1 et se résout en isolant directement la variable." + }, + "quadratic": { + "title": "Classer comme équation quadratique", + "detail": "L'équation est de degré 2 ; la factorisation ou la formule quadratique donneront ses racines." + }, + "cubic": { + "title": "Classer comme équation cubique", + "detail": "L'équation est de degré 3 ; une substitution vers la cubique déprimée et la formule de Cardan s'appliquent." + }, + "rational": { + "title": "Classer comme équation rationnelle", + "detail": "L'équation contient la variable au dénominateur ; il faut donc éliminer les fractions avant de résoudre." + }, + "trigonometric": { + "title": "Classer comme équation trigonométrique", + "detail": "L'équation comporte une fonction trigonométrique ; sa fonction réciproque isolera la variable." + }, + "exponential": { + "title": "Classer comme équation exponentielle", + "detail": "La variable figure en exposant ; des logarithmes ou des méthodes numériques sont nécessaires pour la résoudre." + }, + "logarithmic": { + "title": "Classer comme équation logarithmique", + "detail": "L'équation contient un logarithme de la variable ; exponentier les deux membres l'isolera." + }, + "transcendental": { + "title": "Classer comme équation transcendante", + "detail": "L'équation mélange des termes algébriques et transcendants ; elle se résout donc numériquement." + }, + "higherPolynomial": { + "title": "Classer comme polynôme de degré supérieur", + "detail": "L'équation est de degré 4 ou plus ; une recherche numérique des racines remplace une formule fermée." + } + } + }, + "linear": { + "coefficients": { + "title": "Identifier les coefficients linéaires", + "detail": "Écrivez l'équation sous la forme a·{variable} + b = 0, avec a = {a} et b = {b}." + }, + "isolate": { + "title": "Isoler le terme variable", + "detail": "Soustrayez le terme constant des deux côtés pour obtenir {a}{variable} = {negB}." + }, + "divide": { + "title": "Diviser par le coefficient", + "detail": "Diviser les deux côtés par {a} isole {variable}, donnant {variable} = {solution}." + }, + "noSolution": { + "title": "Signaler l'absence de solution", + "detail": "Le coefficient de la variable est nul alors que la constante {b} ne l'est pas, donc aucune valeur ne satisfait l'équation." + }, + "allReals": { + "title": "Signaler que tous les réels sont solutions", + "detail": "Les deux coefficients sont nuls, donc tout nombre réel satisfait l'équation." + } + }, + "quadratic": { + "coefficients": { + "title": "Identifier les coefficients quadratiques", + "detail": "Écrivez l'équation sous forme standard a{variable}² + b{variable} + c = 0, avec a = {a}, b = {b}, c = {c}." + }, + "factor": { + "title": "Factoriser l'expression quadratique", + "detail": "Trouvez deux nombres dont le produit vaut {ac} et la somme {b}, donnant les racines {r1} et {r2}." + }, + "zeroProduct": { + "title": "Appliquer la propriété du produit nul", + "detail": "Le produit factorisé étant nul, au moins un facteur doit s'annuler : {variable} = {r1} ou {variable} = {r2}." + }, + "solveFactors": { + "title": "Résoudre chaque facteur", + "detail": "En annulant chaque facteur, on obtient les solutions {variable} = {r1} et {variable} = {r2}." + }, + "repeatedRoot": { + "title": "Identifier la racine double", + "detail": "Le discriminant est nul, donc les deux racines coïncident en {variable} = {r} avec une multiplicité de 2." + }, + "formula": { + "title": "Appliquer la formule quadratique", + "detail": "Avec a = {a}, b = {b}, c = {c}, le discriminant b² − 4ac vaut {discriminant}." + }, + "evaluateRoots": { + "title": "Calculer les racines", + "detail": "En substituant dans la formule quadratique, on obtient {variable}₁ = {r1} et {variable}₂ = {r2}." + }, + "complexRoots": { + "title": "Calculer les racines complexes", + "detail": "Le discriminant {discriminant} est négatif, donnant les racines complexes conjuguées {variable} = {realPart} ± {imagPart}i." + } + }, + "cubic": { + "coefficients": { + "title": "Identifier les coefficients cubiques", + "detail": "Écrivez l'équation sous forme standard a{variable}³ + b{variable}² + c{variable} + d = 0, avec a = {a}, b = {b}, c = {c}, d = {d}." + }, + "depress": { + "title": "Réduire la cubique", + "detail": "Décaler la variable de {shift} élimine le terme quadratique, donnant t³ + {p}t + {q} = 0." + }, + "discriminant": { + "title": "Calculer le discriminant cubique", + "detail": "Le discriminant Δ = −4p³ − 27q² vaut {delta}, ce qui détermine le nombre de racines réelles." + }, + "roots": { + "title": "Calculer les racines de la cubique", + "detail": "L'application de la formule de Cardan suivie de la substitution inverse donne les racines {roots} pour {variable}." + } + }, + "trig": { + "identify": { + "title": "Identifier la fonction trigonométrique", + "detail": "L'équation comporte {fn}({variable}) ; isoler ce terme est la première étape." + }, + "isolate": { + "title": "Isoler l'expression trigonométrique", + "detail": "Réarrangez l'équation pour que {fn}({variable}) soit seul égal à une constante." + }, + "inverse": { + "title": "Appliquer la fonction trigonométrique réciproque", + "detail": "Appliquer la réciproque de {fn} aux deux membres donne la solution principale pour {variable}, ainsi que sa famille périodique." + }, + "principal": { + "title": "Calculer la solution principale", + "detail": "La valeur principale trouvée numériquement est {variable} = {solutions}." + } + }, + "rational": { + "domain": { + "title": "Énoncer les restrictions du domaine", + "detail": "Tout dénominateur contenant {variable} doit être non nul : {denominators} ≠ 0." + }, + "multiplyLcd": { + "title": "Multiplier par le dénominateur commun", + "detail": "Multiplier les deux côtés par {lcd} élimine toutes les fractions, laissant une équation polynomiale." + }, + "checkExtraneous": { + "title": "Vérifier les candidats par rapport au domaine", + "detail": "Après exclusion des racines étrangères, {kept} solution(s) candidate(s) respectent les restrictions du domaine." + }, + "extraneousExcluded": { + "title": "Exclure une racine étrangère", + "detail": "Substituer {variable} = {root} annule un dénominateur, elle est donc rejetée comme étrangère." + } + }, + "numeric": { + "newton": { + "title": "Appliquer la méthode de Newton-Raphson", + "detail": "Cette équation n'a pas de solution sous forme close, donc {variable} est affiné itérativement à partir d'une estimation initiale." + }, + "converged": { + "title": "Converger vers une solution", + "detail": "L'itération a convergé après {iterations} étapes, donnant {solutions}." + }, + "failed": { + "title": "Signaler l'absence de convergence", + "detail": "L'itération de Newton-Raphson n'a pas convergé à partir de l'estimation initiale par défaut." + } + }, + "answer": { + "single": { + "title": "Indiquer la réponse finale", + "detail": "L'équation admet exactement une solution : {variable} = {solution}." + }, + "multiple": { + "title": "Indiquer les réponses finales", + "detail": "L'équation admet {count} solutions : {variable} = {solutions}." + }, + "none": { + "title": "Signaler l'absence de solutions réelles", + "detail": "Aucune valeur réelle de la variable ne satisfait l'équation." + } + }, + "limit": { + "setup": { + "title": "Poser la limite", + "detail": "Évaluez la limite de l'expression lorsque {variable} tend vers {point}{direction, select, left { par la gauche} right { par la droite} other {}}." + }, + "pattern": { + "title": "Reconnaître une limite standard", + "detail": "C'est la limite bien connue {pattern}, qui vaut directement {value}." + }, + "direct": { + "title": "Substituer directement le point", + "detail": "L'expression est continue en ce point, donc la limite est égale à la valeur de la fonction {value}." + }, + "indeterminate": { + "title": "Identifier la forme indéterminée", + "detail": "La substitution directe produit la forme indéterminée {form}, une analyse plus poussée est donc nécessaire." + }, + "simplify": { + "title": "Simplifier l'expression", + "detail": "La simplification algébrique produit une expression équivalente dont la limite est plus facile à évaluer." + }, + "lhopital": { + "title": "Appliquer la règle de L'Hôpital", + "detail": "La limite ayant la forme indéterminée {form}, la limite du quotient équivaut à la limite du quotient des dérivées." + }, + "lhopitalDifferentiate": { + "title": "Dériver le numérateur et le dénominateur", + "detail": "Dérivez séparément le numérateur et le dénominateur (itération {iteration}), puis examinez la limite du nouveau quotient." + }, + "lhopitalResult": { + "title": "Évaluer après la règle de L'Hôpital", + "detail": "La substitution dans le quotient dérivé donne {value}." + }, + "series": { + "title": "Développer en série de Taylor", + "detail": "Le terme dominant du numérateur est d'ordre {numPower} et celui du dénominateur d'ordre {denPower} ; leur comparaison détermine la limite." + }, + "seriesResult": { + "title": "Évaluer la comparaison des séries", + "detail": "Le rapport des coefficients dominants des séries donne {value}." + }, + "numerical": { + "title": "Approximer numériquement", + "detail": "Les méthodes symboliques n'ayant pas abouti, l'expression est évaluée en des points proches de la limite pour détecter une convergence." + }, + "numericalResult": { + "title": "Confirmer la convergence numérique", + "detail": "Les valeurs échantillonnées convergent vers {value}." + }, + "value": { + "title": "Indiquer la valeur de la limite", + "detail": "En combinant les étapes précédentes, la limite vaut {value}." + }, + "infinite": { + "title": "Indiquer que la limite est infinie", + "detail": "L'expression croît indéfiniment : la limite vaut {sign}." + }, + "dne": { + "title": "Indiquer que la limite n'existe pas", + "detail": "Aucune limite finie ou infinie n'a pu être établie ; la limite n'existe donc pas." + } + } + }, + "limitTab": { + "label": "Limite", + "shortLabel": "Lim", + "approach": "Point d'approche", + "pointPlaceholder": "ex. 0, inf, -inf", + "direction": "Direction", + "directionBoth": "Des deux côtés", + "directionLeft": "Par la gauche", + "directionRight": "Par la droite", + "invalidPoint": "Saisissez un point d'approche valide : un nombre, inf ou -inf." + }, + "stepCategories": { + "identification": "Identifier", + "simplification": "Simplifier", + "differentiation": "Dériver", + "integration": "Intégrer", + "rearrangement": "Réarranger", + "isolation": "Isoler", + "factorization": "Factoriser", + "formula": "Formule", + "substitution": "Substituer", + "expansion": "Développer", + "identity": "Identité", + "finalAnswer": "Réponse", + "evaluation": "Évaluer", + "limit": "Limite" } }, "complex": { diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 2420ac24..acffa02b 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -1009,6 +1009,283 @@ "directionField": "方向場", "csvExport": "CSVエクスポート", "aboutTitle": "ODE数値解法について" + }, + "stepRules": { + "equation": { + "start": { + "title": "方程式を確認する", + "detail": "元の方程式を書き出し、解くべき変数を特定します。" + }, + "moveTermsLeft": { + "title": "すべての項を一方に移項する", + "detail": "両辺から {rhs} を引き、右辺を0にします。" + }, + "classify": { + "linear": { + "title": "一次方程式として分類する", + "detail": "この方程式は次数1なので、変数を直接分離して解くことができます。" + }, + "quadratic": { + "title": "二次方程式として分類する", + "detail": "この方程式は次数2なので、因数分解または解の公式で解が求まります。" + }, + "cubic": { + "title": "三次方程式として分類する", + "detail": "この方程式は次数3なので、還元三次式への置換とカルダノの公式を用います。" + }, + "rational": { + "title": "分数方程式として分類する", + "detail": "この方程式は分母に変数を含むため、解く前に分数を消去する必要があります。" + }, + "trigonometric": { + "title": "三角方程式として分類する", + "detail": "この方程式は三角関数を含むため、逆三角関数を用いて変数を分離します。" + }, + "exponential": { + "title": "指数方程式として分類する", + "detail": "変数が指数の位置にあるため、対数や数値解法が必要になります。" + }, + "logarithmic": { + "title": "対数方程式として分類する", + "detail": "この方程式は変数の対数を含むため、両辺を指数化することで変数を分離できます。" + }, + "transcendental": { + "title": "超越方程式として分類する", + "detail": "この方程式は代数項と超越項が混在するため、数値的に解きます。" + }, + "higherPolynomial": { + "title": "高次多項式として分類する", + "detail": "この方程式は次数4以上なので、閉じた公式の代わりに数値的な求根法を用います。" + } + } + }, + "linear": { + "coefficients": { + "title": "一次方程式の係数を特定する", + "detail": "方程式を a·{variable} + b = 0 の形で書くと、a = {a}、b = {b} です。" + }, + "isolate": { + "title": "変数の項を分離する", + "detail": "両辺から定数項を引き、{a}{variable} = {negB} を得ます。" + }, + "divide": { + "title": "係数で両辺を割る", + "detail": "両辺を {a} で割ると {variable} が分離され、{variable} = {solution} となります。" + }, + "noSolution": { + "title": "解なしと判定する", + "detail": "変数の係数が0である一方で定数 {b} は0でないため、方程式を満たす値は存在しません。" + }, + "allReals": { + "title": "すべての実数が解であると判定する", + "detail": "両方の係数が0になるため、すべての実数が方程式を満たします。" + } + }, + "quadratic": { + "coefficients": { + "title": "二次方程式の係数を特定する", + "detail": "方程式を標準形 a{variable}² + b{variable} + c = 0 で書くと、a = {a}、b = {b}、c = {c} です。" + }, + "factor": { + "title": "二次式を因数分解する", + "detail": "積が {ac}、和が {b} となる2つの数を見つけると、解 {r1} と {r2} が得られます。" + }, + "zeroProduct": { + "title": "積が0になる性質を適用する", + "detail": "因数分解された積が0なので、少なくとも1つの因数は0になります: {variable} = {r1} または {variable} = {r2}。" + }, + "solveFactors": { + "title": "各因数を解く", + "detail": "各因数を0とおくと、解 {variable} = {r1} と {variable} = {r2} が得られます。" + }, + "repeatedRoot": { + "title": "重解を特定する", + "detail": "判別式が0なので、2つの解は {variable} = {r} で重複度2として一致します。" + }, + "formula": { + "title": "解の公式を適用する", + "detail": "a = {a}、b = {b}、c = {c} のとき、判別式 b² − 4ac は {discriminant} になります。" + }, + "evaluateRoots": { + "title": "解を計算する", + "detail": "解の公式に代入すると {variable}₁ = {r1}、{variable}₂ = {r2} が得られます。" + }, + "complexRoots": { + "title": "複素数解を計算する", + "detail": "判別式 {discriminant} が負であるため、共役複素数解 {variable} = {realPart} ± {imagPart}i が得られます。" + } + }, + "cubic": { + "coefficients": { + "title": "三次方程式の係数を特定する", + "detail": "方程式を標準形 a{variable}³ + b{variable}² + c{variable} + d = 0 で書くと、a = {a}、b = {b}、c = {c}、d = {d} です。" + }, + "depress": { + "title": "三次式を還元する", + "detail": "変数を {shift} だけ平行移動すると二次項が消え、t³ + {p}t + {q} = 0 となります。" + }, + "discriminant": { + "title": "三次方程式の判別式を計算する", + "detail": "判別式 Δ = −4p³ − 27q² は {delta} であり、これにより実数解の個数が決まります。" + }, + "roots": { + "title": "三次方程式の解を計算する", + "detail": "カルダノの公式を適用し逆代入すると、{variable} の解 {roots} が得られます。" + } + }, + "trig": { + "identify": { + "title": "三角関数を特定する", + "detail": "この方程式には {fn}({variable}) が含まれるため、まずこの項を分離します。" + }, + "isolate": { + "title": "三角関数の式を分離する", + "detail": "方程式を並べ替えて {fn}({variable}) だけが定数に等しくなるようにします。" + }, + "inverse": { + "title": "逆三角関数を適用する", + "detail": "両辺に {fn} の逆関数を適用すると、{variable} の主値解とその周期的な解の族が得られます。" + }, + "principal": { + "title": "主値解を計算する", + "detail": "数値的に求めた主値は {variable} = {solutions} です。" + } + }, + "rational": { + "domain": { + "title": "定義域の制約を示す", + "detail": "{variable} を含むすべての分母は0であってはなりません: {denominators} ≠ 0。" + }, + "multiplyLcd": { + "title": "最小公分母を掛ける", + "detail": "両辺に {lcd} を掛けるとすべての分数が消え、多項式の方程式になります。" + }, + "checkExtraneous": { + "title": "候補解を定義域と照合する", + "detail": "無関係な解を除外した結果、定義域の制約を満たす候補解は {kept} 個です。" + }, + "extraneousExcluded": { + "title": "無関係解を除外する", + "detail": "{variable} = {root} を代入すると分母が0になるため、この解は無関係解として除外されます。" + } + }, + "numeric": { + "newton": { + "title": "ニュートン・ラフソン法を適用する", + "detail": "この方程式に閉じた形の解は存在しないため、初期値から反復的に {variable} を改善します。" + }, + "converged": { + "title": "解に収束する", + "detail": "反復は {iterations} 回で収束し、{solutions} が得られました。" + }, + "failed": { + "title": "収束しなかったことを報告する", + "detail": "既定の初期値からのニュートン・ラフソン反復は収束しませんでした。" + } + }, + "answer": { + "single": { + "title": "最終的な答えを示す", + "detail": "この方程式の解はただ1つです: {variable} = {solution}。" + }, + "multiple": { + "title": "最終的な答えを示す", + "detail": "この方程式には {count} 個の解があります: {variable} = {solutions}。" + }, + "none": { + "title": "実数解がないことを報告する", + "detail": "この方程式を満たす実数値の変数は存在しません。" + } + }, + "limit": { + "setup": { + "title": "極限を設定する", + "detail": "{variable} が {point} に近づくとき{direction, select, left {(左側から)} right {(右側から)} other {}}の式の極限を求めます。" + }, + "pattern": { + "title": "標準的な極限を認識する", + "detail": "これはよく知られた極限 {pattern} であり、直接 {value} に評価されます。" + }, + "direct": { + "title": "点を直接代入する", + "detail": "この点で式は連続であるため、極限は関数値 {value} に等しくなります。" + }, + "indeterminate": { + "title": "不定形を特定する", + "detail": "直接代入すると不定形 {form} になるため、さらなる解析が必要です。" + }, + "simplify": { + "title": "式を簡略化する", + "detail": "代数的な簡略化により、極限を求めやすい同値な式が得られます。" + }, + "lhopital": { + "title": "ロピタルの定理を適用する", + "detail": "極限が不定形 {form} であるため、この比の極限は導関数の比の極限に等しくなります。" + }, + "lhopitalDifferentiate": { + "title": "分子と分母を微分する", + "detail": "分子と分母をそれぞれ微分し(反復 {iteration} 回目)、新しい商の極限を調べます。" + }, + "lhopitalResult": { + "title": "ロピタルの定理適用後に評価する", + "detail": "微分後の商に代入すると {value} が得られます。" + }, + "series": { + "title": "テイラー級数に展開する", + "detail": "分子の先頭項の次数は {numPower}、分母の先頭項の次数は {denPower} であり、これらを比較して極限が決まります。" + }, + "seriesResult": { + "title": "級数比較を評価する", + "detail": "先頭項の係数の比は {value} になります。" + }, + "numerical": { + "title": "数値的に近似する", + "detail": "記号的な手法では結論が出なかったため、極限に近づく点で式を評価し収束を確認します。" + }, + "numericalResult": { + "title": "数値的収束を確認する", + "detail": "サンプリングした値は {value} に収束します。" + }, + "value": { + "title": "極限値を示す", + "detail": "これまでの手順をまとめると、極限は {value} になります。" + }, + "infinite": { + "title": "極限が無限大であることを示す", + "detail": "式は無限に増大するため、極限は {sign} です。" + }, + "dne": { + "title": "極限が存在しないことを示す", + "detail": "有限にも無限にも極限を確定できなかったため、極限は存在しません。" + } + } + }, + "limitTab": { + "label": "極限", + "shortLabel": "極限", + "approach": "近づく点", + "pointPlaceholder": "例: 0, inf, -inf", + "direction": "方向", + "directionBoth": "両側から", + "directionLeft": "左側から", + "directionRight": "右側から", + "invalidPoint": "有効な近づく点を入力してください(数値、inf、-inf のいずれか)。" + }, + "stepCategories": { + "identification": "識別", + "simplification": "簡略化", + "differentiation": "微分", + "integration": "積分", + "rearrangement": "整理", + "isolation": "分離", + "factorization": "因数分解", + "formula": "公式", + "substitution": "代入", + "expansion": "展開", + "identity": "恒等", + "finalAnswer": "答え", + "evaluation": "評価", + "limit": "極限" } }, "complex": { diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 3b5e1f2e..676e3764 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -976,6 +976,283 @@ "directionField": "Поле направлений", "csvExport": "Экспорт в CSV", "aboutTitle": "О численных методах решения ОДУ" + }, + "stepRules": { + "equation": { + "start": { + "title": "Записать уравнение", + "detail": "Запишите исходное уравнение и определите переменную, которую нужно найти." + }, + "moveTermsLeft": { + "title": "Перенести все члены в одну сторону", + "detail": "Вычтите {rhs} из обеих частей, чтобы правая часть стала равна нулю." + }, + "classify": { + "linear": { + "title": "Определить как линейное уравнение", + "detail": "Уравнение имеет первую степень, поэтому его можно решить прямым выделением переменной." + }, + "quadratic": { + "title": "Определить как квадратное уравнение", + "detail": "Уравнение имеет вторую степень, поэтому корни находятся разложением на множители или по формуле корней." + }, + "cubic": { + "title": "Определить как кубическое уравнение", + "detail": "Уравнение имеет третью степень, поэтому применяются приведение к неполному кубическому уравнению и формула Кардано." + }, + "rational": { + "title": "Определить как дробно-рациональное уравнение", + "detail": "Переменная содержится в знаменателе, поэтому перед решением нужно избавиться от дробей." + }, + "trigonometric": { + "title": "Определить как тригонометрическое уравнение", + "detail": "Уравнение содержит тригонометрическую функцию, поэтому обратная функция позволит выделить переменную." + }, + "exponential": { + "title": "Определить как показательное уравнение", + "detail": "Переменная находится в показателе степени, поэтому для решения нужны логарифмы или численные методы." + }, + "logarithmic": { + "title": "Определить как логарифмическое уравнение", + "detail": "Уравнение содержит логарифм переменной, поэтому потенцирование обеих частей выделит её." + }, + "transcendental": { + "title": "Определить как трансцендентное уравнение", + "detail": "Уравнение сочетает алгебраические и трансцендентные члены, поэтому решается численно." + }, + "higherPolynomial": { + "title": "Определить как многочлен высшей степени", + "detail": "Уравнение имеет степень 4 или выше, поэтому вместо формулы применяется численный поиск корней." + } + } + }, + "linear": { + "coefficients": { + "title": "Определить коэффициенты линейного уравнения", + "detail": "Запишите уравнение в виде a·{variable} + b = 0, где a = {a}, b = {b}." + }, + "isolate": { + "title": "Выделить член с переменной", + "detail": "Вычтите постоянный член из обеих частей, получив {a}{variable} = {negB}." + }, + "divide": { + "title": "Разделить на коэффициент", + "detail": "Разделив обе части на {a}, получаем {variable} = {solution}." + }, + "noSolution": { + "title": "Указать на отсутствие решения", + "detail": "Коэффициент при переменной равен нулю, а константа {b} — нет, поэтому уравнение не имеет решений." + }, + "allReals": { + "title": "Указать, что решение — все действительные числа", + "detail": "Оба коэффициента равны нулю, поэтому уравнению удовлетворяет любое действительное число." + } + }, + "quadratic": { + "coefficients": { + "title": "Определить коэффициенты квадратного уравнения", + "detail": "Запишите уравнение в стандартном виде a{variable}² + b{variable} + c = 0, где a = {a}, b = {b}, c = {c}." + }, + "factor": { + "title": "Разложить квадратное выражение на множители", + "detail": "Найдите два числа с произведением {ac} и суммой {b} — это даёт корни {r1} и {r2}." + }, + "zeroProduct": { + "title": "Применить свойство нулевого произведения", + "detail": "Так как произведение множителей равно нулю, хотя бы один множитель обращается в нуль: {variable} = {r1} или {variable} = {r2}." + }, + "solveFactors": { + "title": "Решить относительно каждого множителя", + "detail": "Приравняв каждый множитель к нулю, получаем решения {variable} = {r1} и {variable} = {r2}." + }, + "repeatedRoot": { + "title": "Определить кратный корень", + "detail": "Дискриминант равен нулю, поэтому оба корня совпадают: {variable} = {r} с кратностью 2." + }, + "formula": { + "title": "Применить формулу корней", + "detail": "При a = {a}, b = {b}, c = {c} дискриминант b² − 4ac равен {discriminant}." + }, + "evaluateRoots": { + "title": "Вычислить корни", + "detail": "Подставив в формулу корней, получаем {variable}₁ = {r1} и {variable}₂ = {r2}." + }, + "complexRoots": { + "title": "Вычислить комплексные корни", + "detail": "Дискриминант {discriminant} отрицателен, поэтому корни комплексно сопряжённые: {variable} = {realPart} ± {imagPart}i." + } + }, + "cubic": { + "coefficients": { + "title": "Определить коэффициенты кубического уравнения", + "detail": "Запишите уравнение в стандартном виде a{variable}³ + b{variable}² + c{variable} + d = 0, где a = {a}, b = {b}, c = {c}, d = {d}." + }, + "depress": { + "title": "Привести кубическое уравнение к неполному виду", + "detail": "Сдвиг переменной на {shift} устраняет квадратичный член, давая t³ + {p}t + {q} = 0." + }, + "discriminant": { + "title": "Вычислить дискриминант кубического уравнения", + "detail": "Дискриминант Δ = −4p³ − 27q² равен {delta}, что определяет число действительных корней." + }, + "roots": { + "title": "Вычислить корни кубического уравнения", + "detail": "Применение формулы Кардано с обратной подстановкой даёт корни {roots} для {variable}." + } + }, + "trig": { + "identify": { + "title": "Определить тригонометрическую функцию", + "detail": "Уравнение содержит {fn}({variable}), поэтому первым шагом нужно выделить этот член." + }, + "isolate": { + "title": "Выделить тригонометрическое выражение", + "detail": "Преобразуйте уравнение так, чтобы {fn}({variable}) само по себе равнялось константе." + }, + "inverse": { + "title": "Применить обратную тригонометрическую функцию", + "detail": "Применение обратной функции к {fn} с обеих сторон даёт главное решение для {variable} и его периодическое семейство." + }, + "principal": { + "title": "Вычислить главное решение", + "detail": "Главное значение, найденное численно, равно {variable} = {solutions}." + } + }, + "rational": { + "domain": { + "title": "Указать ограничения области определения", + "detail": "Каждый знаменатель, содержащий {variable}, должен быть отличен от нуля: {denominators} ≠ 0." + }, + "multiplyLcd": { + "title": "Умножить на общий знаменатель", + "detail": "Умножение обеих частей на {lcd} устраняет все дроби, оставляя полиномиальное уравнение." + }, + "checkExtraneous": { + "title": "Проверить кандидатов на соответствие области определения", + "detail": "После исключения посторонних корней ограничениям области определения удовлетворяют {kept} кандидат(ов)." + }, + "extraneousExcluded": { + "title": "Исключить посторонний корень", + "detail": "Подстановка {variable} = {root} обращает знаменатель в нуль, поэтому корень отбрасывается как посторонний." + } + }, + "numeric": { + "newton": { + "title": "Применить метод Ньютона", + "detail": "У этого уравнения нет решения в замкнутой форме, поэтому {variable} уточняется итеративно от начального приближения." + }, + "converged": { + "title": "Сойтись к решению", + "detail": "Итерация сошлась за {iterations} шагов, дав {solutions}." + }, + "failed": { + "title": "Сообщить об отсутствии сходимости", + "detail": "Итерация Ньютона не сошлась при начальном приближении по умолчанию." + } + }, + "answer": { + "single": { + "title": "Указать окончательный ответ", + "detail": "Уравнение имеет ровно одно решение: {variable} = {solution}." + }, + "multiple": { + "title": "Указать окончательные ответы", + "detail": "Уравнение имеет {count} решений: {variable} = {solutions}." + }, + "none": { + "title": "Сообщить об отсутствии действительных решений", + "detail": "Ни одно действительное значение переменной не удовлетворяет уравнению." + } + }, + "limit": { + "setup": { + "title": "Составить предел", + "detail": "Вычислите предел выражения при {variable}, стремящемся к {point}{direction, select, left { слева} right { справа} other {}}." + }, + "pattern": { + "title": "Распознать стандартный предел", + "detail": "Это известный предел {pattern}, который сразу равен {value}." + }, + "direct": { + "title": "Подставить точку напрямую", + "detail": "Выражение непрерывно в этой точке, поэтому предел равен значению функции {value}." + }, + "indeterminate": { + "title": "Определить неопределённость", + "detail": "Прямая подстановка даёт неопределённость {form}, поэтому требуется дальнейший анализ." + }, + "simplify": { + "title": "Упростить выражение", + "detail": "Алгебраическое упрощение даёт эквивалентное выражение, предел которого вычислить проще." + }, + "lhopital": { + "title": "Применить правило Лопиталя", + "detail": "Так как предел имеет неопределённость {form}, предел отношения равен пределу отношения производных." + }, + "lhopitalDifferentiate": { + "title": "Продифференцировать числитель и знаменатель", + "detail": "Продифференцируйте числитель и знаменатель отдельно (итерация {iteration}), затем рассмотрите предел нового отношения." + }, + "lhopitalResult": { + "title": "Вычислить после применения правила Лопиталя", + "detail": "Подстановка в продифференцированное отношение даёт {value}." + }, + "series": { + "title": "Разложить в ряд Тейлора", + "detail": "Старший член числителя имеет порядок {numPower}, а знаменателя — {denPower}; их сравнение определяет предел." + }, + "seriesResult": { + "title": "Оценить сравнение рядов", + "detail": "Отношение старших коэффициентов рядов даёт {value}." + }, + "numerical": { + "title": "Приблизить численно", + "detail": "Символьные методы не дали результата, поэтому выражение вычисляется в точках, приближающихся к пределу, для проверки сходимости." + }, + "numericalResult": { + "title": "Подтвердить численную сходимость", + "detail": "Вычисленные значения сходятся к {value}." + }, + "value": { + "title": "Указать значение предела", + "detail": "Объединяя предыдущие шаги, предел равен {value}." + }, + "infinite": { + "title": "Указать, что предел бесконечен", + "detail": "Выражение неограниченно возрастает: предел равен {sign}." + }, + "dne": { + "title": "Указать, что предел не существует", + "detail": "Не удалось установить ни конечный, ни бесконечный предел, поэтому предел не существует." + } + } + }, + "limitTab": { + "label": "Предел", + "shortLabel": "Предел", + "approach": "Точка приближения", + "pointPlaceholder": "напр. 0, inf, -inf", + "direction": "Направление", + "directionBoth": "С обеих сторон", + "directionLeft": "Слева", + "directionRight": "Справа", + "invalidPoint": "Введите допустимую точку приближения: число, inf или -inf." + }, + "stepCategories": { + "identification": "Определение", + "simplification": "Упрощение", + "differentiation": "Дифференцирование", + "integration": "Интегрирование", + "rearrangement": "Перестановка", + "isolation": "Выделение", + "factorization": "Разложение", + "formula": "Формула", + "substitution": "Подстановка", + "expansion": "Раскрытие", + "identity": "Тождество", + "finalAnswer": "Ответ", + "evaluation": "Вычисление", + "limit": "Предел" } }, "complex": { diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 42bf1a9e..6eb45659 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -976,6 +976,283 @@ "directionField": "Direction Field", "csvExport": "CSV Export", "aboutTitle": "About Numerical ODE Methods" + }, + "stepRules": { + "equation": { + "start": { + "title": "Записати рівняння", + "detail": "Запишіть вихідне рівняння та визначте змінну, яку потрібно знайти." + }, + "moveTermsLeft": { + "title": "Перенести всі члени в один бік", + "detail": "Відніміть {rhs} від обох частин, щоб права частина стала нулем." + }, + "classify": { + "linear": { + "title": "Визначити як лінійне рівняння", + "detail": "Рівняння має перший степінь, тому його можна розв'язати прямим виділенням змінної." + }, + "quadratic": { + "title": "Визначити як квадратне рівняння", + "detail": "Рівняння має другий степінь, тому корені знаходяться розкладанням на множники або за формулою коренів." + }, + "cubic": { + "title": "Визначити як кубічне рівняння", + "detail": "Рівняння має третій степінь, тому застосовуються зведення до неповного кубічного рівняння та формула Кардано." + }, + "rational": { + "title": "Визначити як дробово-раціональне рівняння", + "detail": "Змінна міститься в знаменнику, тому перед розв'язанням потрібно позбутися дробів." + }, + "trigonometric": { + "title": "Визначити як тригонометричне рівняння", + "detail": "Рівняння містить тригонометричну функцію, тому обернена функція дозволить виділити змінну." + }, + "exponential": { + "title": "Визначити як показникове рівняння", + "detail": "Змінна перебуває в показнику степеня, тому для розв'язання потрібні логарифми або чисельні методи." + }, + "logarithmic": { + "title": "Визначити як логарифмічне рівняння", + "detail": "Рівняння містить логарифм змінної, тому потенціювання обох частин виділить її." + }, + "transcendental": { + "title": "Визначити як трансцендентне рівняння", + "detail": "Рівняння поєднує алгебраїчні та трансцендентні члени, тому розв'язується чисельно." + }, + "higherPolynomial": { + "title": "Визначити як многочлен вищого степеня", + "detail": "Рівняння має степінь 4 або вищий, тому замість формули застосовується чисельний пошук коренів." + } + } + }, + "linear": { + "coefficients": { + "title": "Визначити коефіцієнти лінійного рівняння", + "detail": "Запишіть рівняння у вигляді a·{variable} + b = 0, де a = {a}, b = {b}." + }, + "isolate": { + "title": "Виділити член зі змінною", + "detail": "Відніміть сталий член з обох частин, отримавши {a}{variable} = {negB}." + }, + "divide": { + "title": "Поділити на коефіцієнт", + "detail": "Поділивши обидві частини на {a}, отримуємо {variable} = {solution}." + }, + "noSolution": { + "title": "Вказати на відсутність розв'язку", + "detail": "Коефіцієнт при змінній дорівнює нулю, а стала {b} — ні, тому рівняння не має розв'язків." + }, + "allReals": { + "title": "Вказати, що розв'язок — усі дійсні числа", + "detail": "Обидва коефіцієнти дорівнюють нулю, тому рівнянню задовольняє будь-яке дійсне число." + } + }, + "quadratic": { + "coefficients": { + "title": "Визначити коефіцієнти квадратного рівняння", + "detail": "Запишіть рівняння у стандартному вигляді a{variable}² + b{variable} + c = 0, де a = {a}, b = {b}, c = {c}." + }, + "factor": { + "title": "Розкласти квадратний вираз на множники", + "detail": "Знайдіть два числа з добутком {ac} і сумою {b} — це дає корені {r1} та {r2}." + }, + "zeroProduct": { + "title": "Застосувати властивість нульового добутку", + "detail": "Оскільки добуток множників дорівнює нулю, хоча б один множник дорівнює нулю: {variable} = {r1} або {variable} = {r2}." + }, + "solveFactors": { + "title": "Розв'язати кожен множник", + "detail": "Прирівнявши кожен множник до нуля, отримуємо розв'язки {variable} = {r1} та {variable} = {r2}." + }, + "repeatedRoot": { + "title": "Визначити кратний корінь", + "detail": "Дискримінант дорівнює нулю, тому обидва корені збігаються: {variable} = {r} з кратністю 2." + }, + "formula": { + "title": "Застосувати формулу коренів", + "detail": "При a = {a}, b = {b}, c = {c} дискримінант b² − 4ac дорівнює {discriminant}." + }, + "evaluateRoots": { + "title": "Обчислити корені", + "detail": "Підставивши у формулу коренів, отримуємо {variable}₁ = {r1} та {variable}₂ = {r2}." + }, + "complexRoots": { + "title": "Обчислити комплексні корені", + "detail": "Дискримінант {discriminant} від'ємний, тому корені комплексно спряжені: {variable} = {realPart} ± {imagPart}i." + } + }, + "cubic": { + "coefficients": { + "title": "Визначити коефіцієнти кубічного рівняння", + "detail": "Запишіть рівняння у стандартному вигляді a{variable}³ + b{variable}² + c{variable} + d = 0, де a = {a}, b = {b}, c = {c}, d = {d}." + }, + "depress": { + "title": "Звести кубічне рівняння до неповного вигляду", + "detail": "Зсув змінної на {shift} усуває квадратичний член, даючи t³ + {p}t + {q} = 0." + }, + "discriminant": { + "title": "Обчислити дискримінант кубічного рівняння", + "detail": "Дискримінант Δ = −4p³ − 27q² дорівнює {delta}, що визначає кількість дійсних коренів." + }, + "roots": { + "title": "Обчислити корені кубічного рівняння", + "detail": "Застосування формули Кардано зі зворотною підстановкою дає корені {roots} для {variable}." + } + }, + "trig": { + "identify": { + "title": "Визначити тригонометричну функцію", + "detail": "Рівняння містить {fn}({variable}), тому першим кроком потрібно виділити цей член." + }, + "isolate": { + "title": "Виділити тригонометричний вираз", + "detail": "Перетворіть рівняння так, щоб {fn}({variable}) само по собі дорівнювало сталій." + }, + "inverse": { + "title": "Застосувати обернену тригонометричну функцію", + "detail": "Застосування оберненої функції до {fn} з обох боків дає головний розв'язок для {variable} та його періодичну сім'ю." + }, + "principal": { + "title": "Обчислити головний розв'язок", + "detail": "Головне значення, знайдене чисельно, дорівнює {variable} = {solutions}." + } + }, + "rational": { + "domain": { + "title": "Вказати обмеження області визначення", + "detail": "Кожен знаменник, що містить {variable}, має бути відмінним від нуля: {denominators} ≠ 0." + }, + "multiplyLcd": { + "title": "Помножити на спільний знаменник", + "detail": "Множення обох частин на {lcd} усуває всі дроби, залишаючи поліноміальне рівняння." + }, + "checkExtraneous": { + "title": "Перевірити кандидатів на відповідність області визначення", + "detail": "Після виключення сторонніх коренів обмеженням області визначення відповідають {kept} кандидат(и)." + }, + "extraneousExcluded": { + "title": "Виключити сторонній корінь", + "detail": "Підстановка {variable} = {root} обертає знаменник у нуль, тому корінь відкидається як сторонній." + } + }, + "numeric": { + "newton": { + "title": "Застосувати метод Ньютона", + "detail": "Це рівняння не має розв'язку в замкненій формі, тому {variable} уточнюється ітеративно від початкового наближення." + }, + "converged": { + "title": "Збігтися до розв'язку", + "detail": "Ітерація збіглася за {iterations} кроків, давши {solutions}." + }, + "failed": { + "title": "Повідомити про відсутність збіжності", + "detail": "Ітерація Ньютона не збіглася за початкового наближення за замовчуванням." + } + }, + "answer": { + "single": { + "title": "Вказати остаточну відповідь", + "detail": "Рівняння має рівно один розв'язок: {variable} = {solution}." + }, + "multiple": { + "title": "Вказати остаточні відповіді", + "detail": "Рівняння має {count} розв'язків: {variable} = {solutions}." + }, + "none": { + "title": "Повідомити про відсутність дійсних розв'язків", + "detail": "Жодне дійсне значення змінної не задовольняє рівняння." + } + }, + "limit": { + "setup": { + "title": "Скласти границю", + "detail": "Обчисліть границю виразу, коли {variable} прямує до {point}{direction, select, left { зліва} right { справа} other {}}." + }, + "pattern": { + "title": "Розпізнати стандартну границю", + "detail": "Це відома границя {pattern}, яка одразу дорівнює {value}." + }, + "direct": { + "title": "Підставити точку напряму", + "detail": "Вираз неперервний у цій точці, тому границя дорівнює значенню функції {value}." + }, + "indeterminate": { + "title": "Визначити невизначеність", + "detail": "Пряма підстановка дає невизначеність {form}, тому потрібен подальший аналіз." + }, + "simplify": { + "title": "Спростити вираз", + "detail": "Алгебраїчне спрощення дає еквівалентний вираз, границю якого обчислити простіше." + }, + "lhopital": { + "title": "Застосувати правило Лопіталя", + "detail": "Оскільки границя має невизначеність {form}, границя відношення дорівнює границі відношення похідних." + }, + "lhopitalDifferentiate": { + "title": "Продиференціювати чисельник і знаменник", + "detail": "Продиференціюйте чисельник і знаменник окремо (ітерація {iteration}), потім розгляньте границю нового відношення." + }, + "lhopitalResult": { + "title": "Обчислити після застосування правила Лопіталя", + "detail": "Підстановка у продиференційоване відношення дає {value}." + }, + "series": { + "title": "Розкласти в ряд Тейлора", + "detail": "Старший член чисельника має порядок {numPower}, а знаменника — {denPower}; їх порівняння визначає границю." + }, + "seriesResult": { + "title": "Оцінити порівняння рядів", + "detail": "Відношення старших коефіцієнтів рядів дає {value}." + }, + "numerical": { + "title": "Наблизити чисельно", + "detail": "Символьні методи не дали результату, тому вираз обчислюється в точках, що наближаються до границі, для перевірки збіжності." + }, + "numericalResult": { + "title": "Підтвердити чисельну збіжність", + "detail": "Обчислені значення збігаються до {value}." + }, + "value": { + "title": "Вказати значення границі", + "detail": "Об'єднавши попередні кроки, границя дорівнює {value}." + }, + "infinite": { + "title": "Вказати, що границя нескінченна", + "detail": "Вираз необмежено зростає: границя дорівнює {sign}." + }, + "dne": { + "title": "Вказати, що границя не існує", + "detail": "Не вдалося встановити ні скінченну, ні нескінченну границю, тому границя не існує." + } + } + }, + "limitTab": { + "label": "Границя", + "shortLabel": "Границя", + "approach": "Точка наближення", + "pointPlaceholder": "напр. 0, inf, -inf", + "direction": "Напрямок", + "directionBoth": "З обох боків", + "directionLeft": "Зліва", + "directionRight": "Справа", + "invalidPoint": "Введіть допустиму точку наближення: число, inf або -inf." + }, + "stepCategories": { + "identification": "Визначення", + "simplification": "Спрощення", + "differentiation": "Диференціювання", + "integration": "Інтегрування", + "rearrangement": "Перестановка", + "isolation": "Виділення", + "factorization": "Розкладання", + "formula": "Формула", + "substitution": "Підстановка", + "expansion": "Розкриття", + "identity": "Тотожність", + "finalAnswer": "Відповідь", + "evaluation": "Обчислення", + "limit": "Границя" } }, "complex": { diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 2531a3af..7da1a05a 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -1009,6 +1009,283 @@ "directionField": "方向场", "csvExport": "CSV 导出", "aboutTitle": "关于数值 ODE 方法" + }, + "stepRules": { + "equation": { + "start": { + "title": "列出方程", + "detail": "写出原始方程,并确定要求解的变量。" + }, + "moveTermsLeft": { + "title": "将所有项移到一边", + "detail": "两边同时减去 {rhs},使右边变为零。" + }, + "classify": { + "linear": { + "title": "归类为一次方程", + "detail": "该方程为一次方程,可通过直接分离变量求解。" + }, + "quadratic": { + "title": "归类为二次方程", + "detail": "该方程为二次方程,可通过因式分解或求根公式求出其根。" + }, + "cubic": { + "title": "归类为三次方程", + "detail": "该方程为三次方程,需通过换元化为简化三次方程并应用卡尔达诺公式求解。" + }, + "rational": { + "title": "归类为分式方程", + "detail": "该方程的分母中含有变量,求解前需先去分母。" + }, + "trigonometric": { + "title": "归类为三角方程", + "detail": "该方程含有三角函数,需使用反三角函数分离变量。" + }, + "exponential": { + "title": "归类为指数方程", + "detail": "变量出现在指数位置,需要使用对数或数值方法求解。" + }, + "logarithmic": { + "title": "归类为对数方程", + "detail": "该方程含有变量的对数,对两边取指数即可分离变量。" + }, + "transcendental": { + "title": "归类为超越方程", + "detail": "该方程同时含有代数项和超越项,需通过数值方法求解。" + }, + "higherPolynomial": { + "title": "归类为高次多项式", + "detail": "该方程次数为4次或更高,需使用数值求根法而非封闭公式求解。" + } + } + }, + "linear": { + "coefficients": { + "title": "确定一次方程的系数", + "detail": "将方程写成 a·{variable} + b = 0 的形式,其中 a = {a},b = {b}。" + }, + "isolate": { + "title": "分离含变量的项", + "detail": "两边同时减去常数项,得到 {a}{variable} = {negB}。" + }, + "divide": { + "title": "两边除以系数", + "detail": "两边同时除以 {a},分离出 {variable} = {solution}。" + }, + "noSolution": { + "title": "判定无解", + "detail": "变量的系数为零,而常数 {b} 不为零,因此没有值能满足该方程。" + }, + "allReals": { + "title": "判定所有实数皆为解", + "detail": "两个系数都为零,因此任何实数都满足该方程。" + } + }, + "quadratic": { + "coefficients": { + "title": "确定二次方程的系数", + "detail": "将方程写成标准形式 a{variable}² + b{variable} + c = 0,其中 a = {a},b = {b},c = {c}。" + }, + "factor": { + "title": "分解二次式", + "detail": "找出两个数,其积为 {ac}、和为 {b},由此得到根 {r1} 和 {r2}。" + }, + "zeroProduct": { + "title": "应用零因子性质", + "detail": "由于分解后的乘积为零,至少有一个因子为零:{variable} = {r1} 或 {variable} = {r2}。" + }, + "solveFactors": { + "title": "求解每个因式", + "detail": "令每个因式为零,得到两个解 {variable} = {r1} 和 {variable} = {r2}。" + }, + "repeatedRoot": { + "title": "确定重根", + "detail": "判别式为零,因此两个根重合于 {variable} = {r},重数为2。" + }, + "formula": { + "title": "应用求根公式", + "detail": "当 a = {a}、b = {b}、c = {c} 时,判别式 b² − 4ac 等于 {discriminant}。" + }, + "evaluateRoots": { + "title": "计算根的值", + "detail": "代入求根公式,得到 {variable}₁ = {r1},{variable}₂ = {r2}。" + }, + "complexRoots": { + "title": "计算复数根", + "detail": "判别式 {discriminant} 为负,因此得到共轭复数根 {variable} = {realPart} ± {imagPart}i。" + } + }, + "cubic": { + "coefficients": { + "title": "确定三次方程的系数", + "detail": "将方程写成标准形式 a{variable}³ + b{variable}² + c{variable} + d = 0,其中 a = {a},b = {b},c = {c},d = {d}。" + }, + "depress": { + "title": "化简三次方程", + "detail": "将变量平移 {shift} 可消去二次项,得到 t³ + {p}t + {q} = 0。" + }, + "discriminant": { + "title": "计算三次方程的判别式", + "detail": "判别式 Δ = −4p³ − 27q² 等于 {delta},由此决定实根的个数。" + }, + "roots": { + "title": "计算三次方程的根", + "detail": "应用卡尔达诺公式并回代,得到 {variable} 的根为 {roots}。" + } + }, + "trig": { + "identify": { + "title": "识别三角函数", + "detail": "该方程包含 {fn}({variable}),第一步是分离出这一项。" + }, + "isolate": { + "title": "分离三角表达式", + "detail": "重新整理方程,使 {fn}({variable}) 单独等于一个常数。" + }, + "inverse": { + "title": "应用反三角函数", + "detail": "对两边应用 {fn} 的反函数,得到 {variable} 的主值解及其周期解族。" + }, + "principal": { + "title": "计算主值解", + "detail": "通过数值方法求得的主值为 {variable} = {solutions}。" + } + }, + "rational": { + "domain": { + "title": "给出定义域限制", + "detail": "所有含 {variable} 的分母都不能为零:{denominators} ≠ 0。" + }, + "multiplyLcd": { + "title": "乘以最小公分母", + "detail": "两边同乘以 {lcd},消去所有分数,得到一个多项式方程。" + }, + "checkExtraneous": { + "title": "对候选解进行定义域检验", + "detail": "排除增根后,共有 {kept} 个候选解满足定义域限制。" + }, + "extraneousExcluded": { + "title": "排除增根", + "detail": "代入 {variable} = {root} 会使分母为零,因此该解作为增根被排除。" + } + }, + "numeric": { + "newton": { + "title": "应用牛顿-拉夫逊迭代法", + "detail": "该方程没有封闭形式的解,因此从初始猜测值出发,通过迭代逐步逼近 {variable}。" + }, + "converged": { + "title": "迭代收敛到解", + "detail": "迭代经过 {iterations} 步收敛,得到 {solutions}。" + }, + "failed": { + "title": "报告未收敛", + "detail": "从默认初始猜测值出发,牛顿-拉夫逊迭代未能收敛。" + } + }, + "answer": { + "single": { + "title": "给出最终答案", + "detail": "该方程恰有一个解:{variable} = {solution}。" + }, + "multiple": { + "title": "给出最终答案", + "detail": "该方程共有 {count} 个解:{variable} = {solutions}。" + }, + "none": { + "title": "报告无实数解", + "detail": "没有实数值能满足该方程。" + } + }, + "limit": { + "setup": { + "title": "建立极限", + "detail": "当 {variable} 趋于 {point}{direction, select, left {(从左侧)} right {(从右侧)} other {}}时,求该表达式的极限。" + }, + "pattern": { + "title": "识别标准极限", + "detail": "这是已知的标准极限 {pattern},其值直接为 {value}。" + }, + "direct": { + "title": "直接代入该点", + "detail": "该表达式在此点连续,因此极限等于函数值 {value}。" + }, + "indeterminate": { + "title": "识别不定式", + "detail": "直接代入得到不定式 {form},需要进一步分析。" + }, + "simplify": { + "title": "化简表达式", + "detail": "通过代数化简得到一个等价表达式,其极限更容易求出。" + }, + "lhopital": { + "title": "应用洛必达法则", + "detail": "由于极限为不定式 {form},该比值的极限等于其导数比值的极限。" + }, + "lhopitalDifferentiate": { + "title": "对分子和分母求导", + "detail": "分别对分子和分母求导(第 {iteration} 次迭代),然后考察新商的极限。" + }, + "lhopitalResult": { + "title": "应用洛必达法则后求值", + "detail": "代入求导后的商,得到 {value}。" + }, + "series": { + "title": "展开为泰勒级数", + "detail": "分子首项的阶为 {numPower},分母首项的阶为 {denPower};比较两者可确定极限。" + }, + "seriesResult": { + "title": "求值级数比较结果", + "detail": "首项系数之比为 {value}。" + }, + "numerical": { + "title": "数值逼近", + "detail": "符号方法未能得出结论,因此在趋近极限的点上对表达式取值以检验其是否收敛。" + }, + "numericalResult": { + "title": "确认数值收敛", + "detail": "采样值收敛于 {value}。" + }, + "value": { + "title": "给出极限值", + "detail": "综合以上步骤,该极限等于 {value}。" + }, + "infinite": { + "title": "指出极限为无穷", + "detail": "该表达式无限增长:极限为 {sign}。" + }, + "dne": { + "title": "指出极限不存在", + "detail": "无法确定有限或无限的极限,因此该极限不存在。" + } + } + }, + "limitTab": { + "label": "极限", + "shortLabel": "极限", + "approach": "趋近点", + "pointPlaceholder": "例如 0, inf, -inf", + "direction": "方向", + "directionBoth": "双侧", + "directionLeft": "从左侧", + "directionRight": "从右侧", + "invalidPoint": "请输入有效的趋近点:数字、inf 或 -inf。" + }, + "stepCategories": { + "identification": "识别", + "simplification": "化简", + "differentiation": "求导", + "integration": "积分", + "rearrangement": "整理", + "isolation": "分离", + "factorization": "因式分解", + "formula": "公式", + "substitution": "代入", + "expansion": "展开", + "identity": "恒等", + "finalAnswer": "答案", + "evaluation": "求值", + "limit": "极限" } }, "complex": { From 51fbd1a06677c012eabc075af65a67b886998d68 Mon Sep 17 00:00:00 2001 From: ABCrimson <231791317+ABCrimson@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:57:02 -0500 Subject: [PATCH 3/4] feat(web): wire the solver panel's Limit tab to StepTrace i18n Wires the previously-uncommitted Limit mode tab (approach point + direction controls, parseLimitPoint/parseLimitDirection) and the localizeStep() mapper that resolves ruleId-tagged solution steps through solver.stepRules..{title,detail} with the engine's plain-value params, falling back to the engine's English text for untagged steps. Depends on the i18n keys added in the previous commit. Co-Authored-By: Claude Fable 5 --- .../components/calculator/solver-panel.tsx | 249 ++++++++++++++++-- 1 file changed, 223 insertions(+), 26 deletions(-) diff --git a/apps/web/components/calculator/solver-panel.tsx b/apps/web/components/calculator/solver-panel.tsx index 1793c683..7eb9994e 100644 --- a/apps/web/components/calculator/solver-panel.tsx +++ b/apps/web/components/calculator/solver-panel.tsx @@ -9,12 +9,14 @@ import { ChevronDown, ChevronUp, Copy, + Infinity as InfinityIcon, Loader2, RotateCcw, Sigma, TrendingUp, } from 'lucide-react'; import { AnimatePresence, m } from 'motion/react'; +import { useTranslations } from 'next-intl'; import type { KeyboardEvent } from 'react'; import { useCallback, useId, useState, useTransition } from 'react'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -52,7 +54,22 @@ type VariableName = string & { readonly __brand: unique symbol }; * Problem mode supported by the step-by-step solver. * Discriminated union used to drive tab selection and example lookup. */ -type ProblemMode = 'equation' | 'simplify' | 'derivative' | 'integral'; +type ProblemMode = 'equation' | 'simplify' | 'derivative' | 'integral' | 'limit'; + +/** Approach point of a limit (mirrors the engine's LimitPoint). */ +type LimitApproachPoint = number | 'infinity' | '-infinity'; + +/** Approach direction of a limit (mirrors the engine's LimitDirection). */ +type LimitDirectionOption = 'both' | 'left' | 'right'; + +/** + * Translator shape used by the localization mapper. Structural subset of + * next-intl's `useTranslations()` return value (call signature + `has`). + */ +interface SolverTranslator { + (key: string, values?: Readonly>): string; + has(key: string): boolean; +} /** Category badge color mapping */ type StepCategoryStyle = { @@ -170,6 +187,12 @@ const CATEGORY_STYLES: Readonly> = { border: 'border-cyan-500/30', label: 'Evaluate', }, + Limit: { + bg: 'bg-fuchsia-500/10', + text: 'text-fuchsia-700 dark:text-fuchsia-300', + border: 'border-fuchsia-500/30', + label: 'Limit', + }, } satisfies Record; const DEFAULT_CATEGORY_STYLE: StepCategoryStyle = { @@ -179,10 +202,16 @@ const DEFAULT_CATEGORY_STYLE: StepCategoryStyle = { label: 'Step', }; +/** One selectable example expression (limit examples also carry a point) */ +interface ModeExample { + readonly label: string; + readonly input: string; + readonly desc: string; + readonly point?: string; +} + /** Example expressions per mode */ -const MODE_EXAMPLES: Readonly< - Record> -> = { +const MODE_EXAMPLES: Readonly>> = { equation: [ { label: 'Linear', input: '2*x + 3 = 7', desc: 'Simple linear equation' }, { @@ -224,6 +253,12 @@ const MODE_EXAMPLES: Readonly< { label: 'Constant', input: '5', desc: '∫5 dx = 5x' }, { label: 'Log', input: '1/x', desc: '∫1/x dx = ln|x|' }, ], + limit: [ + { label: 'Standard', input: 'sin(x)/x', desc: 'Known limit → 1', point: '0' }, + { label: "L'Hôpital", input: '(exp(x) - 1)/x', desc: "0/0 form → L'Hôpital → 1", point: '0' }, + { label: 'Squeeze', input: '(1 - cos(x))/x^2', desc: 'Series comparison → 1/2', point: '0' }, + { label: 'At infinity', input: '1/x', desc: '→ 0 as x → ∞', point: 'inf' }, + ], }; /** Mode metadata for tabs */ @@ -250,6 +285,11 @@ const MODE_META: Readonly< placeholder: 'x^2 + 2*x', hint: 'Enter a function of x. The antiderivative ∫ dx will be computed symbolically.', }, + limit: { + label: 'Limit', + placeholder: 'sin(x)/x', + hint: 'Enter a function and the approach point (a number, inf, or -inf). Steps show the technique used.', + }, }; // ============================================================================ @@ -721,13 +761,77 @@ function validateVariable(raw: string): VariableName { return 'x' as VariableName; } +/** + * Parse the approach-point input into a limit point. + * Accepts numbers plus the infinity spellings inf/∞/infinity (± variants). + * Returns null for anything unparseable — caller shows the error alert. + */ +function parseLimitPoint(raw: string): LimitApproachPoint | null { + const text = raw.trim().toLowerCase(); + if (text.length === 0) return null; + if (['inf', '+inf', '∞', '+∞', 'infinity', '+infinity'].includes(text)) return 'infinity'; + if (['-inf', '-∞', '-infinity'].includes(text)) return '-infinity'; + const value = Number(text); + return Number.isFinite(value) ? value : null; +} + +/** Narrow a Select value to a limit direction (defaults to 'both'). */ +function parseLimitDirection(raw: string): LimitDirectionOption { + return raw === 'left' || raw === 'right' ? raw : 'both'; +} + +/** + * Engine solution step shape consumed by the localization mapper — + * structural subset of math-engine's SolutionStep. + */ +interface EngineSolutionStep { + readonly stepNumber: number; + readonly description: string; + readonly explanation: string; + readonly operation: string; + readonly category: string; + readonly latex?: string; + readonly ruleId?: string; + readonly params?: Readonly>; + readonly to: unknown; +} + +/** + * Localize one engine step: steps tagged with a `ruleId` whose translation + * exists resolve through `solver.stepRules..{title,detail}` with the + * engine's plain-value params; untagged steps (derivative/integral/simplify + * modes) keep the engine's English text. + */ +function localizeStep(step: EngineSolutionStep, t: SolverTranslator): RenderedStep { + const latex = step.latex ?? astNodeToLatex(step.to); + const base = { + stepNumber: step.stepNumber, + operation: step.operation, + category: step.category, + latex, + }; + + if (step.ruleId && t.has(`stepRules.${step.ruleId}.title`)) { + const params = step.params ?? {}; + return { + ...base, + description: t(`stepRules.${step.ruleId}.title`, params), + explanation: t(`stepRules.${step.ruleId}.detail`, params), + }; + } + + return { ...base, description: step.description, explanation: step.explanation }; +} + // ============================================================================ // SUB-COMPONENTS // ============================================================================ -/** Step category badge */ +/** Step category badge — localized label with English fallback */ function CategoryBadge({ category }: { category: string }) { + const t = useTranslations('solver'); const style = CATEGORY_STYLES[category] ?? DEFAULT_CATEGORY_STYLE; + const labelKey = `stepCategories.${category.charAt(0).toLowerCase()}${category.slice(1)}`; return ( - {style.label} + {t.has(labelKey) ? t(labelKey) : style.label} ); } @@ -853,7 +957,7 @@ function StepCard({ step, index, isExpanded, onToggle, isFinal }: StepCardProps) interface ExampleButtonsProps { mode: ProblemMode; - onSelect: (input: string) => void; + onSelect: (example: ModeExample) => void; } function ExampleButtons({ mode, onSelect }: ExampleButtonsProps) { @@ -869,7 +973,7 @@ function ExampleButtons({ mode, onSelect }: ExampleButtonsProps) {