import {
MathNode,
MathExpression,
SimplificationOptions,
SimplificationResult,
SimplificationStep,
OperatorNode
} from '../../types-advanced-math.js';
import { ExpressionParser } from './ExpressionParser.js';
/**
* Expression simplifier for algebraic expressions
* Handles combining like terms, distributive property, factorization
*/
export class ExpressionSimplifier {
private parser: ExpressionParser;
constructor() {
this.parser = new ExpressionParser();
}
/**
* Simplifies an algebraic expression
* @param expression The expression string
* @param options Optional simplification options
* @returns Simplified expression with steps
*/
simplifyExpression(expression: string, options?: SimplificationOptions): SimplificationResult {
// Parse the expression
const parsed = this.parser.parseExpression(expression);
// Apply simplification rules
const steps: SimplificationStep[] = [];
let currentNode = parsed.parsed;
let currentExpression = expression;
// Set default options
const opts: SimplificationOptions = {
factorize: true,
expandDistributive: true,
combineTerms: true,
rationalizeDenominators: true,
eliminateNesting: true,
...options
};
// Apply simplification steps in order
const simplificationSteps = [
{
name: 'Eliminate nesting',
enabled: opts.eliminateNesting,
apply: () => this.eliminateNesting(currentNode)
},
{
name: 'Expand distributive',
enabled: opts.expandDistributive,
apply: () => this.applyDistributive(currentNode)
},
{
name: 'Combine like terms',
enabled: opts.combineTerms,
apply: () => this.combineLikeTerms(currentNode)
},
{
name: 'Factorize',
enabled: opts.factorize,
apply: () => this.factorizeExpression(currentNode)
}
];
// Apply each enabled step
for (const step of simplificationSteps) {
if (!step.enabled) continue;
const result = step.apply();
if (result.changed) {
currentNode = result.node;
currentExpression = this.parser.nodeToString(currentNode);
steps.push({
result: currentExpression,
rule: step.name,
explanation: this.getStepExplanation(step.name)
});
}
}
// If no steps were performed, add a "no simplification needed" step
if (steps.length === 0) {
steps.push({
result: expression,
rule: 'No simplification',
explanation: 'The expression is already in its simplest form.'
});
}
// Final result
const simplified = this.parser.nodeToString(currentNode);
return {
original: expression,
simplified,
steps
};
}
/**
* Get explanation for a simplification step
* @param stepName Name of the step
* @returns Explanation string
*/
private getStepExplanation(stepName: string): string {
const explanations: Record<string, string> = {
'Eliminate nesting': 'Simplified nested operations of the same type.',
'Expand distributive': 'Applied the distributive property: a(b + c) = ab + ac.',
'Combine like terms': 'Combined terms with the same variables.',
'Factorize': 'Factored common terms from the expression.'
};
return explanations[stepName] || 'Applied simplification rule.';
}
/**
* Combines like terms in an expression
* @param node The expression node
* @returns The simplified node and whether it changed
*/
combineLikeTerms(node: MathNode): { node: MathNode, changed: boolean } {
if (node.type !== 'operator') {
return { node, changed: false };
}
// Recursively process operands first
const processedOperands = node.operands.map(op => this.combineLikeTerms(op));
const childrenChanged = processedOperands.some(r => r.changed);
if (node.operator !== '+' && node.operator !== '-') {
// For non-addition operators, just update operands if children changed
return {
node: childrenChanged ? { ...node, operands: processedOperands.map(r => r.node) } : node,
changed: childrenChanged
};
}
// Flatten addition/subtraction operations
const terms = this.flattenAdditionSubtraction({
...node,
operands: processedOperands.map(r => r.node)
});
// Group like terms
const termGroups = this.groupLikeTerms(terms);
// Combine coefficients
const combinedTerms = this.combineTermGroups(termGroups);
// Check if anything changed
const changed = childrenChanged || combinedTerms.length !== terms.length ||
!this.areTermsEqual(terms, combinedTerms);
// Reconstruct the expression
if (combinedTerms.length === 0) {
return { node: { type: 'number', value: 0 }, changed: true };
}
if (combinedTerms.length === 1) {
return { node: combinedTerms[0], changed };
}
return {
node: {
type: 'operator',
operator: '+',
operands: combinedTerms
},
changed
};
}
/**
* Flattens nested addition/subtraction operations
* @param node The operator node
* @returns Array of terms with their signs
*/
private flattenAdditionSubtraction(node: OperatorNode): MathNode[] {
const terms: MathNode[] = [];
for (let i = 0; i < node.operands.length; i++) {
const operand = node.operands[i];
if (i > 0 && node.operator === '-') {
// Negate the term
terms.push(this.negateTerm(operand));
} else if (operand.type === 'operator' && operand.operator === '+') {
// Flatten nested addition
terms.push(...operand.operands);
} else {
terms.push(operand);
}
}
return terms;
}
/**
* Negates a term
* @param term The term to negate
* @returns Negated term
*/
private negateTerm(term: MathNode): MathNode {
if (term.type === 'number') {
return { type: 'number', value: -term.value };
}
if (term.type === 'operator' && term.operator === '*' &&
term.operands[0].type === 'number') {
// Negate the coefficient
return {
...term,
operands: [
{ type: 'number', value: -term.operands[0].value },
...term.operands.slice(1)
]
};
}
// General case: multiply by -1
return {
type: 'operator',
operator: '*',
operands: [{ type: 'number', value: -1 }, term]
};
}
/**
* Groups like terms together
* @param terms Array of terms
* @returns Map of term signatures to terms
*/
private groupLikeTerms(terms: MathNode[]): Map<string, MathNode[]> {
const groups = new Map<string, MathNode[]>();
for (const term of terms) {
const signature = this.getTermSignature(term);
if (!groups.has(signature)) {
groups.set(signature, []);
}
groups.get(signature)!.push(term);
}
return groups;
}
/**
* Gets a signature for a term (ignoring coefficients)
* @param term The term
* @returns Signature string
*/
private getTermSignature(term: MathNode): string {
if (term.type === 'number') {
return 'CONSTANT';
}
if (term.type === 'variable') {
return term.name;
}
if (term.type === 'operator' && term.operator === '*') {
// Extract non-numeric parts
const nonNumeric = term.operands.filter(op => op.type !== 'number');
if (nonNumeric.length === 0) return 'CONSTANT';
if (nonNumeric.length === 1) return this.getTermSignature(nonNumeric[0]);
// Multiple non-numeric parts
return nonNumeric.map(op => this.getTermSignature(op)).sort().join('*');
}
// For other expressions, use string representation
return this.parser.nodeToString(term);
}
/**
* Combines terms in each group
* @param groups Map of term groups
* @returns Array of combined terms
*/
private combineTermGroups(groups: Map<string, MathNode[]>): MathNode[] {
const combined: MathNode[] = [];
for (const [signature, terms] of groups) {
if (signature === 'CONSTANT') {
// Sum all constants
const sum = terms.reduce((acc, term) => {
const value = term.type === 'number' ? term.value : this.extractCoefficient(term);
return acc + value;
}, 0);
if (Math.abs(sum) > 1e-10) {
combined.push({ type: 'number', value: sum });
}
} else {
// Sum coefficients for like terms
let totalCoefficient = 0;
let templateTerm: MathNode | null = null;
for (const term of terms) {
const { coefficient, rest } = this.extractCoefficientAndRest(term);
totalCoefficient += coefficient;
if (!templateTerm) templateTerm = rest;
}
if (Math.abs(totalCoefficient) > 1e-10 && templateTerm) {
combined.push(this.createTermWithCoefficient(totalCoefficient, templateTerm));
}
}
}
return combined;
}
/**
* Extracts coefficient from a term
* @param term The term
* @returns Coefficient value
*/
private extractCoefficient(term: MathNode): number {
if (term.type === 'number') {
return term.value;
}
if (term.type === 'operator' && term.operator === '*' &&
term.operands[0].type === 'number') {
return term.operands[0].value;
}
return 1;
}
/**
* Extracts coefficient and rest from a term
* @param term The term
* @returns Coefficient and rest of the term
*/
private extractCoefficientAndRest(term: MathNode): { coefficient: number, rest: MathNode } {
if (term.type === 'number') {
return { coefficient: term.value, rest: { type: 'number', value: 1 } };
}
if (term.type === 'variable') {
return { coefficient: 1, rest: term };
}
if (term.type === 'operator' && term.operator === '*' &&
term.operands[0].type === 'number') {
const rest = term.operands.length === 2 ? term.operands[1] : {
type: 'operator',
operator: '*',
operands: term.operands.slice(1)
} as OperatorNode;
return { coefficient: term.operands[0].value, rest };
}
return { coefficient: 1, rest: term };
}
/**
* Creates a term with a given coefficient
* @param coefficient The coefficient
* @param term The term (without coefficient)
* @returns Complete term
*/
private createTermWithCoefficient(coefficient: number, term: MathNode): MathNode {
if (coefficient === 1) {
return term;
}
if (coefficient === -1 && term.type === 'variable') {
return {
type: 'operator',
operator: '*',
operands: [{ type: 'number', value: -1 }, term]
};
}
return {
type: 'operator',
operator: '*',
operands: [{ type: 'number', value: coefficient }, term]
};
}
/**
* Checks if two term arrays are equal
* @param terms1 First array
* @param terms2 Second array
* @returns True if equal
*/
private areTermsEqual(terms1: MathNode[], terms2: MathNode[]): boolean {
if (terms1.length !== terms2.length) return false;
const sigs1 = terms1.map(t => this.parser.nodeToString(t)).sort();
const sigs2 = terms2.map(t => this.parser.nodeToString(t)).sort();
return sigs1.every((sig, i) => sig === sigs2[i]);
}
/**
* Applies the distributive property to an expression
* @param node The expression node
* @returns The simplified node and whether it changed
*/
applyDistributive(node: MathNode): { node: MathNode, changed: boolean } {
if (node.type !== 'operator') {
return { node, changed: false };
}
// Recursively process operands
const processedOperands = node.operands.map(op => this.applyDistributive(op));
const childrenChanged = processedOperands.some(r => r.changed);
const operands = processedOperands.map(r => r.node);
if (node.operator !== '*') {
return {
node: childrenChanged ? { ...node, operands } : node,
changed: childrenChanged
};
}
// Look for multiplication involving addition/subtraction
let changed = childrenChanged;
let result: MathNode = { ...node, operands };
for (let i = 0; i < operands.length; i++) {
const operand = operands[i];
if (operand.type === 'operator' && (operand.operator === '+' || operand.operator === '-')) {
// Found a(b + c) pattern
const otherFactors = [...operands.slice(0, i), ...operands.slice(i + 1)];
// Distribute
const distributedTerms = operand.operands.map(term => ({
type: 'operator',
operator: '*',
operands: [...otherFactors, term]
} as OperatorNode));
result = {
type: 'operator',
operator: operand.operator,
operands: distributedTerms
};
changed = true;
break;
}
}
return { node: result, changed };
}
/**
* Eliminates nested operations of the same type
* @param node The expression node
* @returns The simplified node and whether it changed
*/
eliminateNesting(node: MathNode): { node: MathNode, changed: boolean } {
if (node.type !== 'operator') {
return { node, changed: false };
}
// Recursively process operands
const processedOperands = node.operands.map(op => this.eliminateNesting(op));
const childrenChanged = processedOperands.some(r => r.changed);
const operands = processedOperands.map(r => r.node);
// Check for nested operations of the same type
if (node.operator === '+' || node.operator === '*') {
const newOperands: MathNode[] = [];
let changed = childrenChanged;
for (const operand of operands) {
if (operand.type === 'operator' && operand.operator === node.operator) {
// Flatten nested operation
newOperands.push(...operand.operands);
changed = true;
} else {
newOperands.push(operand);
}
}
return {
node: { ...node, operands: newOperands },
changed
};
}
return {
node: childrenChanged ? { ...node, operands } : node,
changed: childrenChanged
};
}
/**
* Factorizes common terms from an expression
* @param node The expression node
* @returns The factorized node and whether it changed
*/
factorizeExpression(node: MathNode): { node: MathNode, changed: boolean } {
if (node.type !== 'operator' || node.operator !== '+') {
// Recursively process operands
if (node.type === 'operator') {
const processedOperands = node.operands.map(op => this.factorizeExpression(op));
const changed = processedOperands.some(r => r.changed);
return {
node: changed ? { ...node, operands: processedOperands.map(r => r.node) } : node,
changed
};
}
return { node, changed: false };
}
// Look for common factors
const terms = node.operands;
// Extract all factors from each term
const termFactors = terms.map(term => this.extractFactors(term));
// Find common factors
const commonFactors = this.findCommonFactors(termFactors);
if (commonFactors.length === 0) {
return { node, changed: false };
}
// Factor out common factors
const factoredTerms = termFactors.map(factors => {
const remaining = factors.filter(f =>
!commonFactors.some(cf => this.parser.nodeToString(cf) === this.parser.nodeToString(f))
);
if (remaining.length === 0) {
return { type: 'number', value: 1 } as MathNode;
} else if (remaining.length === 1) {
return remaining[0];
} else {
return {
type: 'operator',
operator: '*',
operands: remaining
} as OperatorNode;
}
});
// Build factored expression
const factoredSum: MathNode = factoredTerms.length === 1 ? factoredTerms[0] : {
type: 'operator',
operator: '+',
operands: factoredTerms
};
const result: MathNode = {
type: 'operator',
operator: '*',
operands: [...commonFactors, factoredSum]
};
return { node: result, changed: true };
}
/**
* Extracts factors from a term
* @param term The term
* @returns Array of factors
*/
private extractFactors(term: MathNode): MathNode[] {
if (term.type === 'operator' && term.operator === '*') {
return term.operands;
}
return [term];
}
/**
* Finds common factors among term factors
* @param termFactors Array of factor arrays
* @returns Common factors
*/
private findCommonFactors(termFactors: MathNode[][]): MathNode[] {
if (termFactors.length === 0) return [];
const commonFactors: MathNode[] = [];
const firstTermFactors = termFactors[0];
for (const factor of firstTermFactors) {
const factorStr = this.parser.nodeToString(factor);
// Check if this factor appears in all terms
const appearsInAll = termFactors.every(factors =>
factors.some(f => this.parser.nodeToString(f) === factorStr)
);
if (appearsInAll) {
commonFactors.push(factor);
}
}
return commonFactors;
}
}