@teachinglab/omd
Version:
omd
888 lines (778 loc) • 41.6 kB
JavaScript
import { omdRationalNode } from "../nodes/omdRationalNode.js";
import { omdBinaryExpressionNode } from "../nodes/omdBinaryExpressionNode.js";
import { omdUnaryExpressionNode } from "../nodes/omdUnaryExpressionNode.js";
import { omdVariableNode } from "../nodes/omdVariableNode.js";
import { omdPowerNode } from "../nodes/omdPowerNode.js";
import { omdConstantNode } from "../nodes/omdConstantNode.js";
import { createConstantNode, applyProvenance } from './simplificationUtils.js';
import * as utils from './simplificationUtils.js';
import { useImplicitMultiplication, getMultiplicationSymbol } from "../config/omdConfigManager.js";
/**
* @class SimplificationEngine
* @classdesc Provides a collection of static methods for creating, matching, and transforming
* mathematical expression nodes for simplification purposes.
* This class serves as the core logic for applying simplification rules within the OMD system.
*/
export class SimplificationEngine {
/**
* ===== SIMPLIFIED NODE CREATION HELPERS =====
* These functions provide an easy way to create various types of AST nodes
* without directly dealing with the complexities of the Math.js AST structure.
*/
/**
* Creates a new constant node.
* @param {number} value - The numeric value of the constant.
* @param {number} [fontSize=16] - The font size for the node.
* @param {...omdNode} sourceNodes - Source nodes for automatic provenance tracking.
* @returns {Object} A new constant node.
*/
static createConstant(value, fontSize = 16, ...sourceNodes) {
const node = createConstantNode(value, fontSize);
// Apply manual provenance tracking if source nodes provided
if (sourceNodes.length > 0) {
applyProvenance(node, ...sourceNodes);
}
return node;
}
/**
* Creates a new binary expression node.
* @param {Object} left - The left-hand side operand node.
* @param {string} operator - The operator (e.g., '+', '-', '*', '/').
* @param {Object} right - The right-hand side operand node.
* @param {number} [fontSize=16] - The font size for the node.
* @param {Array|null} [provenance=null] - The provenance array for the node (deprecated - use ...sourceNodes instead).
* @param {omdNode|null} [operatorNode=null] - The original operator node for provenance (deprecated - use ...sourceNodes instead).
* @param {...omdNode} sourceNodes - Additional source nodes for automatic provenance tracking.
* @returns {Object} A new binary expression node.
* @throws {Error} If an unknown operator is provided.
*/
static createBinaryOp(left, operator, right, fontSize = 16, provenance = null, operatorNode = null, ...sourceNodes) {
const opMap = {
'add': { op: '+', fn: 'add' },
'subtract': { op: '−', fn: 'subtract' },
'multiply': { op: getMultiplicationSymbol(), fn: 'multiply' },
'divide': { op: '÷', fn: 'divide' }
};
const opInfo = opMap[operator];
if (!opInfo) throw new Error(`Unknown operator: ${operator}`);
// For multiplication, reorder operands if needed (e.g., x*2 -> 2*x)
let leftOperand = left;
let rightOperand = right;
if (operator === 'multiply') {
const leftIsConstant = (left instanceof omdConstantNode && left.isConstant());
const rightIsConstant = (right instanceof omdConstantNode && right.isConstant());
// If left is non-constant and right is constant, swap them
if (!leftIsConstant && rightIsConstant) {
leftOperand = right;
rightOperand = left;
}
}
const ast = {
type: 'OperatorNode',
op: opInfo.op,
fn: opInfo.fn,
args: [leftOperand.toMathJSNode(), rightOperand.toMathJSNode()],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
// Pass operator provenance into the constructor via the AST
if (operatorNode) {
ast.operatorProvenance = [operatorNode.id];
}
const node = new omdBinaryExpressionNode(ast);
node.setFontSize(fontSize);
// Use manual provenance tracking
if (provenance) {
// Legacy support: apply manual provenance
node.provenance = provenance;
} else {
// Manual provenance tracking for binary operations
const allSources = [leftOperand, rightOperand];
if (operatorNode) allSources.push(operatorNode);
allSources.push(...sourceNodes);
applyProvenance(node, ...allSources);
}
node.initialize();
return node;
}
/**
* Creates a multiplication expression where the left operand is a constant value.
* @param {Object} leftConstantNode - The constant node for the left operand.
* @param {Object} rightNode - The node for the right operand.
* @param {number} [fontSize=16] - The font size for the node.
* @param {Array|null} [provenance=null] - The provenance array for the node.
* @returns {Object} A new binary expression node representing multiplication.
*/
static createMultiplication(leftConstantNode, rightNode, fontSize = 16, provenance = null) {
// Clone the constant to create a new instance for the new expression,
// but ensure its provenance points back to the original constant.
const newLeftConstant = leftConstantNode.clone();
newLeftConstant.provenance = [leftConstantNode.id];
const rightClone = rightNode.clone(); // Clone the other node to ensure we don't modify the original
// Safely get the operator node for provenance, handling cases where parent might be null
const operatorNode = leftConstantNode.parent?.op || null;
return SimplificationEngine.createBinaryOp(newLeftConstant, 'multiply', rightClone, fontSize, provenance, operatorNode);
}
/**
* Creates a rational node (fraction).
* @param {number} numerator - The numerator value.
* @param {number} denominator - The denominator value.
* @param {number} [fontSize=16] - The font size for the node.
* @param {...omdNode} sourceNodes - Source nodes for automatic provenance tracking.
* @returns {Object} A new rational node, or a constant node if the denominator is 1.
*/
static createRational(numerator, denominator, fontSize = 16, ...sourceNodes) {
if (denominator === 1) {
return SimplificationEngine.createConstant(numerator, fontSize, ...sourceNodes);
}
const ast = {
type: 'OperatorNode', op: '/', fn: 'divide',
args: [
{ type: 'ConstantNode', value: Math.abs(numerator) },
{ type: 'ConstantNode', value: denominator }
],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
let node = new omdRationalNode(ast);
// Handle negative fractions by wrapping in a unary minus node
if (numerator < 0) {
const unaryAST = {
type: 'OperatorNode', op: '-', fn: 'unaryMinus',
args: [node.toMathJSNode()],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
node = new omdUnaryExpressionNode(unaryAST);
}
node.setFontSize(fontSize);
// Apply manual provenance tracking if source nodes provided
if (sourceNodes.length > 0) {
applyProvenance(node, ...sourceNodes);
}
node.initialize();
return node;
}
/**
* ===== SIMPLIFIED PATTERN MATCHING HELPERS =====
* These functions provide convenient ways to check common patterns in AST nodes.
*/
/**
* Checks if a node is of a specific type.
* @param {Object} node - The node to check.
* @param {string} typeName - The name of the constructor type (e.g., 'omdConstantNode').
* @returns {boolean} True if the node matches the type, false otherwise.
*/
static isType(node, typeName) {
if (!node) return false;
return node.type === typeName;
}
/**
* Checks if a node is a binary operation, optionally with a specific operator.
* @param {Object} node - The node to check.
* @param {string} [operator=null] - The specific operator to check for (e.g., 'add', 'multiply').
* @returns {boolean} True if the node is a binary operation (and matches operator if provided), false otherwise.
*/
static isBinaryOp(node, operator = null) {
if (!SimplificationEngine.isType(node, 'omdBinaryExpressionNode')) return false;
if (!operator) return true;
// Handle both string operations and object operations (from Math.js AST)
let nodeOp = node.operation;
if (typeof nodeOp === 'object' && nodeOp.name) {
nodeOp = nodeOp.name;
}
return nodeOp === operator;
}
/**
* Checks if a node is a constant, optionally with a specific value.
* @param {Object} node - The node to check.
* @param {number} [value=null] - The specific constant value to check for.
* @returns {boolean} True if the node is a constant (and matches value if provided), false otherwise.
*/
static isConstantValue(node, value = null) {
if (!node.isConstant()) return false;
return value !== null ? node.getValue() === value : true;
}
/**
* Checks if a binary operation node has a constant operand and returns it along with the other operand.
* @param {Object} node - The binary expression node to check.
* @returns {Object|null} An object containing the constant and other operand, or null if no constant operand is found.
*/
static hasConstantOperand(node) {
if (!SimplificationEngine.isBinaryOp(node)) return null;
if (node.left.isConstant()) return { constant: node.left, other: node.right };
if (node.right.isConstant()) return { constant: node.right, other: node.left };
return null;
}
/**
* Unwraps a parenthesis node, returning its inner expression. If the node is not a parenthesis node, it returns the node itself.
* @param {Object} node - The node to unwrap.
* @returns {Object} The inner expression if the node is a parenthesis node, otherwise the original node.
*/
static unwrapParentheses(node) {
return SimplificationEngine.isType(node, 'omdParenthesisNode') ? node.expression : node;
}
/**
* ===== MONOMIAL AND LIKE TERMS HELPERS =====
* These functions help identify and work with monomials and like terms.
*/
/**
* Checks if a node represents a monomial (a term of the form coefficient * variable^power).
* Examples: x, 2x, 3y, -5z, x^2, 2x^3
* @param {Object} node - The node to check.
* @returns {Object|null} An object with coefficient, variable, and power if it's a monomial, null otherwise.
*/
static isMonomial(node) {
let coefficientMultiplier = 1;
// Unwrap parentheses first, as they can contain a unary minus
node = SimplificationEngine.unwrapParentheses(node);
// Handle any number of nested unary minuses by repeatedly unwrapping them
// and flipping the coefficient multiplier.
while (SimplificationEngine.isType(node, 'omdUnaryExpressionNode') && node.operation === 'unaryMinus') {
node = node.argument;
coefficientMultiplier *= -1;
// The argument of the unary minus could also be in parentheses
node = SimplificationEngine.unwrapParentheses(node);
}
// Case 1: Just a variable (e.g., x)
if (SimplificationEngine.isType(node, 'omdVariableNode')) {
return {
coefficient: 1 * coefficientMultiplier,
variable: node.name,
power: 1,
variableNode: node
};
}
// Case 2: Constant * Variable (e.g., 2x, 3y)
if (SimplificationEngine.isBinaryOp(node, 'multiply')) {
const constOp = SimplificationEngine.hasConstantOperand(node);
if (constOp && SimplificationEngine.isType(constOp.other, 'omdVariableNode')) {
return {
coefficient: constOp.constant.getValue() * coefficientMultiplier,
variable: constOp.other.name,
power: 1,
variableNode: constOp.other,
coefficientNode: constOp.constant
};
}
// Case 3: Variable * Constant (e.g., x*2, y*3) - less common but possible
if (constOp && SimplificationEngine.isType(constOp.constant, 'omdVariableNode') && constOp.other.isConstant()) {
return {
coefficient: constOp.other.getValue() * coefficientMultiplier,
variable: constOp.constant.name,
power: 1,
variableNode: constOp.constant,
coefficientNode: constOp.other
};
}
// Case 4: Constant * Power (e.g., 2*x^3)
const constPowerOp = SimplificationEngine.hasConstantOperand(node);
if (constPowerOp && SimplificationEngine.isType(constPowerOp.other, 'omdPowerNode')) {
const powerNode = constPowerOp.other;
if (SimplificationEngine.isType(powerNode.base, 'omdVariableNode') && powerNode.exponent.isConstant()) {
return {
coefficient: constPowerOp.constant.getValue() * coefficientMultiplier,
variable: powerNode.base.name,
power: powerNode.exponent.getValue(),
variableNode: powerNode.base,
coefficientNode: constPowerOp.constant,
powerNode: powerNode
};
}
}
}
// Case 5: Just a power (e.g., x^2)
if (SimplificationEngine.isType(node, 'omdPowerNode')) {
if (SimplificationEngine.isType(node.base, 'omdVariableNode') && node.exponent.isConstant()) {
return {
coefficient: 1 * coefficientMultiplier,
variable: node.base.name,
power: node.exponent.getValue(),
variableNode: node.base,
powerNode: node
};
}
}
return null;
}
/**
* Checks if two monomials are like terms (same variable and power).
* @param {Object} monomial1 - First monomial info from isMonomial().
* @param {Object} monomial2 - Second monomial info from isMonomial().
* @returns {boolean} True if they are like terms, false otherwise.
*/
static areLikeTerms(monomial1, monomial2) {
return monomial1.variable === monomial2.variable && monomial1.power === monomial2.power;
}
/**
* Creates a monomial node from coefficient, variable, and power.
* @param {number} coefficient - The coefficient of the monomial.
* @param {string} variable - The variable name.
* @param {number} power - The power of the variable.
* @param {number} fontSize - The font size for the node.
* @param {Array} [provenance=[]] - The provenance array to preserve lineage.
* @returns {Object} A new monomial node.
*/
static createMonomial(coefficient, variable, power, fontSize, provenance = []) {
// Create variable node
const variableAST = { type: 'SymbolNode', name: variable, clone: function() { return {...this}; } };
const variableNode = new (SimplificationEngine.getNodeClass('omdVariableNode'))(variableAST);
variableNode.setFontSize(fontSize);
variableNode.initialize();
// CRITICAL: Set provenance on the variable node
if (provenance && provenance.length > 0) {
variableNode.provenance = [...provenance];
}
let termNode = variableNode;
// Add power if not 1
if (power !== 1) {
const powerAST = {
type: 'OperatorNode',
op: '^',
fn: 'pow',
args: [variableAST, { type: 'ConstantNode', value: power, clone: function() { return {...this}; } }],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
termNode = new (SimplificationEngine.getNodeClass('omdPowerNode'))(powerAST);
termNode.setFontSize(fontSize);
termNode.initialize();
// CRITICAL: Set provenance on the power node and its components
if (provenance && provenance.length > 0) {
termNode.provenance = [...provenance];
// Set provenance on the base (variable) and exponent
if (termNode.base) {
termNode.base.provenance = [...provenance];
}
if (termNode.exponent) {
termNode.exponent.provenance = [...provenance];
}
}
}
let result;
if (coefficient === 1) {
result = termNode;
} else if (coefficient === -1) {
// Create unary minus
const unaryAST = {
type: 'OperatorNode',
op: '-',
fn: 'unaryMinus',
args: [termNode.toMathJSNode()],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
result = new omdUnaryExpressionNode(unaryAST);
result.setFontSize(fontSize);
result.initialize();
// CRITICAL: Set provenance on unary minus and its argument
if (provenance && provenance.length > 0) {
result.provenance = [...provenance];
if (result.argument) {
result.argument.provenance = [...provenance];
}
}
} else {
// Create coefficient * term
const coeffNode = SimplificationEngine.createConstant(Math.abs(coefficient), fontSize);
// CRITICAL: Set provenance on the coefficient node
if (provenance && provenance.length > 0) {
coeffNode.provenance = [...provenance];
}
const multiplicationNode = SimplificationEngine.createBinaryOp(coeffNode, 'multiply', termNode, fontSize);
// CRITICAL: Set provenance on the multiplication node
if (provenance && provenance.length > 0) {
multiplicationNode.provenance = [...provenance];
// Ensure left (coefficient) and right (variable/power) have provenance
if (multiplicationNode.left) {
multiplicationNode.left.provenance = [...provenance];
}
if (multiplicationNode.right) {
multiplicationNode.right.provenance = [...provenance];
}
}
// Wrap in unary minus if coefficient is negative
if (coefficient < 0) {
const unaryAST = {
type: 'OperatorNode',
op: '-',
fn: 'unaryMinus',
args: [multiplicationNode.toMathJSNode()],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
result = new omdUnaryExpressionNode(unaryAST);
result.setFontSize(fontSize);
result.initialize();
// CRITICAL: Set provenance on negative wrapper and all its components
if (provenance && provenance.length > 0) {
result.provenance = [...provenance];
if (result.argument) {
result.argument.provenance = [...provenance];
// Also set on the multiplication components within the unary minus
if (result.argument.left) {
result.argument.left.provenance = [...provenance];
}
if (result.argument.right) {
result.argument.right.provenance = [...provenance];
}
}
}
} else {
result = multiplicationNode;
}
}
// Preserve provenance from the original terms that were combined
if (provenance && provenance.length > 0) {
provenance.forEach(id => {
if (!result.provenance.includes(id)) {
result.provenance.push(id);
}
});
}
return result;
}
/**
* Helper to get node class by name (needed for dynamic instantiation).
* @param {string} className - The class name.
* @returns {Function} The class constructor.
*/
static getNodeClass(className) {
const classMap = {
'omdVariableNode': omdVariableNode,
'omdPowerNode': omdPowerNode,
'omdUnaryExpressionNode': omdUnaryExpressionNode
};
return classMap[className];
}
/**
* ===== SIMPLIFIED RULE ENGINE CLASS =====
* Defines the structure and behavior of a simplification rule.
*/
static SimplificationRule = class SimplificationRule {
/**
* Creates an instance of SimplificationRule.
* @param {string} name - The name of the rule.
* @param {function(Object): boolean|Object|null} matchFn - A function that attempts to match the rule against a node. Returns `false`, `null`, `undefined` for no match, `true` for a match with no additional data, or an object with data for a match.
* @param {function(Object, Object): Object} transformFn - A function that transforms the matched node. Receives the node and data from `matchFn`.
* @param {function(Object, Object, Object): string} [messageFn=null] - A function that generates a human-readable message for the transformation. Receives the original node, rule data, and the new node.
* @param {string} [type=null] - The type/category of the rule (e.g., 'rational', 'arithmetic', 'algebraic').
*/
constructor(name, matchFn, transformFn, messageFn = null, type = null) {
this.name = name;
this.type = type;
this.match = matchFn;
this.transform = transformFn;
this.message = messageFn;
}
/**
* Determines if the rule can be applied to a given node.
* @param {Object} node - The node to check.
* @returns {Object|null} An object containing rule data if the rule can be applied, otherwise null.
*/
canApply(node) {
const result = this.match(node);
return result === false || result === null || result === undefined ? null :
result === true ? {} : result; // Normalize match results
}
/**
* Applies the transformation defined by the rule to a node.
* @param {Object} node - The node to transform.
* @param {Object} ruleData - Data returned by the `matchFn`.
* @param {Object} currentRoot - The current root of the expression tree.
* @returns {Object} An object indicating success, the new root, and history information.
*/
apply(node, ruleData, currentRoot) {
try {
// Ensure the transform function is valid
if (typeof this.transform !== 'function') {
throw new Error(`Invalid transform function for rule: ${this.name}`);
}
const newNode = this.transform(node, ruleData, currentRoot);
if (!newNode) {
throw new Error(`Transform function for rule '${this.name}' did not return a new node.`);
}
// Collect the IDs of all nodes that are about to be replaced.
const affectedNodeIds = this._collectAffectedNodeIds(node);
const { success, newRoot } = utils._replaceNodeInTree(node, newNode, currentRoot);
if (success) {
// Use custom affected nodes if provided
const finalAffectedIds = newNode.__affectedNodeIds || affectedNodeIds;
const finalResultNodeId = newNode.__resultNodeId || newNode.id;
const historyEntry = {
name: this.name,
affectedNodes: finalAffectedIds,
resultNodeId: finalResultNodeId,
resultProvSources: newNode.provenance,
message: this.message(node, ruleData, newNode)
};
return { success: true, newRoot, historyEntry };
} else {
return { success: false, newRoot: currentRoot };
}
} catch (error) {
console.error(`Error applying rule '${this.name}':`, error);
return { success: false, newRoot: currentRoot };
}
}
/**
* Collects IDs of nodes directly affected by this rule application.
* For rules that create new nodes with provenance, use that to determine affected nodes
* Otherwise, use the direct node ID.
* @param {Object} node - The original node that was transformed.
* @returns {Array<string>} Array of node IDs that were affected.
*/
_collectAffectedNodeIds(node) {
// For simplification rules that set provenance, use that to determine affected nodes
if (node && node.provenance && node.provenance.length > 0) {
// Return the provenance IDs (excluding the original node ID which represents the transformation result)
return node.provenance.filter(id => id !== node.id);
}
// Fallback: return the direct node ID
return node && node.id ? [node.id] : [];
}
/**
* Generates a human-readable message for this rule application.
* @param {Object} originalNode - The original node before transformation.
* @param {Object} ruleData - Data from the match function.
* @param {Object} newNode - The new node after transformation.
* @returns {string} A human-readable message describing what happened.
*/
_generateMessage(originalNode, ruleData, newNode) {
if (this.message) {
try {
return this.message(originalNode, ruleData, newNode);
} catch (error) {
console.warn(`Error generating message for rule ${this.name}:`, error);
}
}
// Default message if no custom message function is provided
const originalValue = originalNode.toString ? originalNode.toString() : 'expression';
const newValue = newNode.toString ? newNode.toString() : 'expression';
return `Applied ${this.name}: "${originalValue}" → "${newValue}"`;
}
}
/**
* ===== HELPER FUNCTIONS FOR COMMON RULE PATTERNS =====
* These functions create common types of simplification rules.
*/
/**
* Creates a rational number node, simplifying it by dividing by the greatest common divisor.
* Handles negative numerators and denominators to ensure a positive denominator.
* @param {number} numerator - The numerator of the rational number.
* @param {number} denominator - The denominator of the rational number.
* @param {number} fontSize - The font size for the resulting node.
* @returns {Object} A new rational node or a constant node if the denominator simplifies to 1.
*/
static rational(numerator, denominator, fontSize) {
const gcd = utils.gcd(Math.abs(numerator), Math.abs(denominator));
const simpleNum = numerator / gcd;
const simpleDen = denominator / gcd;
// Ensure denominator is positive
if (simpleDen < 0) {
return SimplificationEngine.#createRationalSafe(-simpleNum, -simpleDen, fontSize);
}
return SimplificationEngine.#createRationalSafe(simpleNum, simpleDen, fontSize);
}
/**
* A safe internal function to create a rational node, handling simplification and negative signs.
* @param {number} numerator - The numerator.
* @param {number} denominator - The denominator.
* @param {number} fontSize - The font size.
* @returns {Object} The created omdRationalNode or omdConstantNode.
*/
static #createRationalSafe(numerator, denominator, fontSize) {
if (denominator === 1) {
const result = SimplificationEngine.createConstant(numerator, fontSize);
return result;
}
const ast = {
type: 'OperatorNode',
op: '/',
fn: 'divide',
args: [
{ type: 'ConstantNode', value: Math.abs(numerator), clone: function() { return {...this}; } },
{ type: 'ConstantNode', value: denominator, clone: function() { return {...this}; } }
],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
let node = new omdRationalNode(ast);
// Wrap in unary minus if the numerator is negative
if (numerator < 0) {
const unaryAST = {
type: 'OperatorNode',
op: '-',
fn: 'unaryMinus',
args: [node.toMathJSNode()],
clone: function() { return { ...this, args: this.args.map(arg => arg.clone()) }; }
};
node = new omdUnaryExpressionNode(unaryAST);
}
node.setFontSize(fontSize);
node.initialize();
return node;
}
/**
* Combines two constant nodes based on a given operator.
* @param {Object} left - The left constant node.
* @param {Object} right - The right constant node.
* @param {string} operator - The operation to perform (add, subtract, multiply, divide).
* @param {number} fontSize - The font size for the resulting node.
* @returns {Object|null} A new constant or rational node representing the combined value, or null if division by zero occurs.
*/
static combineConstants(left, right, operator, fontSize) {
const leftVal = left.getValue();
const rightVal = right.getValue();
let resultNode = null;
switch(operator) {
case 'add':
resultNode = SimplificationEngine.createConstant(leftVal + rightVal, fontSize, left, right);
break;
case 'subtract':
resultNode = SimplificationEngine.createConstant(leftVal - rightVal, fontSize, left, right);
break;
case 'multiply':
resultNode = SimplificationEngine.createConstant(leftVal * rightVal, fontSize, left, right);
break;
case 'divide':
if (rightVal === 0) return null; // Avoid division by zero
resultNode = SimplificationEngine.rational(leftVal, rightVal, fontSize, left, right);
break;
default:
return null;
}
// The automatic provenance tracking is now handled by the create methods above
return resultNode;
}
/**
* ===== RULE FACTORY FUNCTIONS =====
* Functions to create instances of SimplificationRule for common simplification patterns.
*/
/**
* Creates a new SimplificationRule instance.
* @param {string} name - The name of the rule.
* @param {function(Object): boolean|Object|null} matchFn - The match function for the rule.
* @param {function(Object, Object): Object} transformFn - The transform function for the rule.
* @param {function(Object, Object, Object): string} [messageFn=null] - Optional function to generate human-readable messages.
* @param {string} [type=null] - The type/category of the rule (e.g., 'rational', 'arithmetic', 'algebraic').
* @returns {SimplificationEngine.SimplificationRule} A new SimplificationRule instance.
*/
static createRule(name, matchFn, transformFn, messageFn = null, type = null) {
return new SimplificationEngine.SimplificationRule(name, matchFn, transformFn, messageFn, type);
}
/**
* Creates a rule for folding (simplifying) binary operations with two constant operands.
* @param {string} name - The name of the constant fold rule.
* @param {string} operator - The operator of the binary expression (e.g., 'add', 'subtract').
* @returns {SimplificationEngine.SimplificationRule} A new SimplificationRule for constant folding.
*/
static createConstantFoldRule(name, operator) {
return SimplificationEngine.createRule(name,
// Match: binary op with two constant or rational constant operands
(node) => {
if (!SimplificationEngine.isBinaryOp(node, operator)) return false;
return node.left.isConstant() && node.right.isConstant();
},
// Transform: combine the constants
(node) => {
const newNode = SimplificationEngine.combineConstants(node.left, node.right, operator, node.getFontSize());
// The combineConstants function now correctly handles its own provenance.
// No need to manually add the parent node's id.
return newNode;
},
// Message: describe the constant folding operation
(originalNode, ruleData, newNode) => {
const leftVal = originalNode.left.getValue();
const rightVal = originalNode.right.getValue();
const result = newNode.getValue ? newNode.getValue() : newNode.toString();
const operatorSymbols = {
'add': '+',
'subtract': '-',
'multiply': getMultiplicationSymbol(),
'divide': '÷'
};
const symbol = operatorSymbols[operator] || operator;
return `Combined constants: ${leftVal} ${symbol} ${rightVal} = ${result}`;
}
);
}
/**
* Creates an identity rule (e.g., x + 0 → x, x * 1 → x).
* @param {string} name - The name of the identity rule.
* @param {string} operator - The operator of the binary expression.
* @param {number} identityValue - The identity value for the operation (e.g., 0 for addition, 1 for multiplication).
* @param {'left'|'right'|'both'} [side='both'] - Specifies which side the identity value should be on ('left', 'right', or 'both').
* @returns {SimplificationEngine.SimplificationRule} A new SimplificationRule for the identity operation.
*/
static createIdentityRule(name, operator, identityValue, side = 'both') {
return SimplificationEngine.createRule(name,
// Match: binary op with one constant operand being the identity value
(node) => {
if (!SimplificationEngine.isBinaryOp(node, operator)) return false;
const constOperandInfo = SimplificationEngine.hasConstantOperand(node);
if (!constOperandInfo || !constOperandInfo.constant.isConstant() || constOperandInfo.constant.getValue() !== identityValue) {
return false;
}
if (side === 'left' && node.right.isConstant()) return false;
if (side === 'right' && node.left.isConstant()) return false;
return { other: constOperandInfo.other };
},
// Transform: return the other operand
(node, data) => {
const newNode = data.other.clone();
// Use manual provenance tracking for substitution
applyProvenance(newNode, node);
// Add a flag to tell the highlighting engine that this was a simple identity transform.
newNode.__isSimpleIdentity = true;
return newNode;
},
// Message: describe the identity operation
(originalNode, ruleData, newNode) => {
const { other } = ruleData;
const otherStr = other.toString ? other.toString() : 'expression';
const operatorNames = {
'add': 'addition',
'subtract': 'subtraction',
'multiply': 'multiplication',
'divide': 'division'
};
const operatorSymbols = {
'add': '+',
'subtract': '-',
'multiply': getMultiplicationSymbol(),
'divide': '÷'
};
const opName = operatorNames[operator] || operator;
const symbol = operatorSymbols[operator] || operator;
const isLeftIdentity = originalNode.left.isConstant() && originalNode.left.getValue() === identityValue;
const position = isLeftIdentity ? 'left' : 'right';
if (operator === 'add' && identityValue === 0) {
return `Applied additive identity: "${otherStr} + 0" simplified to "${otherStr}" (adding 0 doesn't change the value)`;
} else if (operator === 'subtract' && identityValue === 0) {
return `Applied subtraction identity: "${otherStr} - 0" simplified to "${otherStr}" (subtracting 0 doesn't change the value)`;
} else if (operator === 'multiply' && identityValue === 1) {
return `Applied multiplicative identity: "${otherStr} ${getMultiplicationSymbol()} 1" simplified to "${otherStr}" (multiplying by 1 doesn't change the value)`;
} else if (operator === 'divide' && identityValue === 1) {
return `Applied division identity: "${otherStr} ÷ 1" simplified to "${otherStr}" (dividing by 1 doesn't change the value)`;
} else {
return `Applied ${opName} identity: removed ${identityValue} from ${position} side, leaving "${otherStr}"`;
}
}
);
}
/**
* Creates a rule for simplifying zero multiplication (e.g., x * 0 = 0).
*/
static createZeroMultiplicationRule() {
return SimplificationEngine.createRule("Zero Multiplication",
(node) => {
if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
const constOp = SimplificationEngine.hasConstantOperand(node);
return constOp && constOp.constant.getValue() === 0;
},
(node) => {
const newNode = SimplificationEngine.createConstant(0, node.getFontSize(), node);
return newNode;
},
(originalNode, ruleData, newNode) => {
const constOp = SimplificationEngine.hasConstantOperand(originalNode);
const otherOperand = constOp.other;
return `Applied zero multiplication: ${utils.nodeToString(otherOperand)} ${getMultiplicationSymbol()} 0 = 0`;
}
);
}
}