@teachinglab/omd 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/README.md +257 -251
  2. package/README.old.md +137 -137
  3. package/canvas/core/canvasConfig.js +202 -202
  4. package/canvas/drawing/segment.js +167 -167
  5. package/canvas/drawing/stroke.js +385 -385
  6. package/canvas/events/eventManager.js +444 -444
  7. package/canvas/events/pointerEventHandler.js +262 -262
  8. package/canvas/index.js +48 -48
  9. package/canvas/tools/PointerTool.js +71 -71
  10. package/canvas/tools/tool.js +222 -222
  11. package/canvas/utils/boundingBox.js +377 -377
  12. package/canvas/utils/mathUtils.js +258 -258
  13. package/docs/api/configuration-options.md +198 -198
  14. package/docs/api/eventManager.md +82 -82
  15. package/docs/api/focusFrameManager.md +144 -144
  16. package/docs/api/index.md +105 -105
  17. package/docs/api/main.md +62 -62
  18. package/docs/api/omdBinaryExpressionNode.md +86 -86
  19. package/docs/api/omdCanvas.md +83 -83
  20. package/docs/api/omdConfigManager.md +112 -112
  21. package/docs/api/omdConstantNode.md +52 -52
  22. package/docs/api/omdDisplay.md +87 -87
  23. package/docs/api/omdEquationNode.md +174 -174
  24. package/docs/api/omdEquationSequenceNode.md +258 -258
  25. package/docs/api/omdEquationStack.md +192 -192
  26. package/docs/api/omdFunctionNode.md +82 -82
  27. package/docs/api/omdGroupNode.md +78 -78
  28. package/docs/api/omdHelpers.md +87 -87
  29. package/docs/api/omdLeafNode.md +85 -85
  30. package/docs/api/omdNode.md +201 -201
  31. package/docs/api/omdOperationDisplayNode.md +117 -117
  32. package/docs/api/omdOperatorNode.md +91 -91
  33. package/docs/api/omdParenthesisNode.md +133 -133
  34. package/docs/api/omdPopup.md +191 -191
  35. package/docs/api/omdPowerNode.md +131 -131
  36. package/docs/api/omdRationalNode.md +144 -144
  37. package/docs/api/omdSequenceNode.md +128 -128
  38. package/docs/api/omdSimplification.md +78 -78
  39. package/docs/api/omdSqrtNode.md +144 -144
  40. package/docs/api/omdStepVisualizer.md +146 -146
  41. package/docs/api/omdStepVisualizerHighlighting.md +65 -65
  42. package/docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  43. package/docs/api/omdStepVisualizerLayout.md +70 -70
  44. package/docs/api/omdStepVisualizerNodeUtils.md +140 -140
  45. package/docs/api/omdStepVisualizerTextBoxes.md +76 -76
  46. package/docs/api/omdToolbar.md +130 -130
  47. package/docs/api/omdTranscriptionService.md +95 -95
  48. package/docs/api/omdTreeDiff.md +169 -169
  49. package/docs/api/omdUnaryExpressionNode.md +137 -137
  50. package/docs/api/omdUtilities.md +82 -82
  51. package/docs/api/omdVariableNode.md +123 -123
  52. package/docs/api/selectTool.md +74 -74
  53. package/docs/api/simplificationEngine.md +97 -97
  54. package/docs/api/simplificationRules.md +76 -76
  55. package/docs/api/simplificationUtils.md +64 -64
  56. package/docs/api/transcribe.md +43 -43
  57. package/docs/api-reference.md +85 -85
  58. package/docs/index.html +453 -453
  59. package/docs/index.md +38 -38
  60. package/docs/omd-objects.md +258 -258
  61. package/index.js +79 -79
  62. package/jsvg/index.js +3 -0
  63. package/jsvg/jsvg.js +898 -898
  64. package/jsvg/jsvgComponents.js +357 -358
  65. package/npm-docs/DOCUMENTATION_SUMMARY.md +220 -220
  66. package/npm-docs/README.md +251 -251
  67. package/npm-docs/api/api-reference.md +85 -85
  68. package/npm-docs/api/configuration-options.md +198 -198
  69. package/npm-docs/api/eventManager.md +82 -82
  70. package/npm-docs/api/expression-nodes.md +561 -561
  71. package/npm-docs/api/focusFrameManager.md +144 -144
  72. package/npm-docs/api/index.md +105 -105
  73. package/npm-docs/api/main.md +62 -62
  74. package/npm-docs/api/omdBinaryExpressionNode.md +86 -86
  75. package/npm-docs/api/omdCanvas.md +83 -83
  76. package/npm-docs/api/omdConfigManager.md +112 -112
  77. package/npm-docs/api/omdConstantNode.md +52 -52
  78. package/npm-docs/api/omdDisplay.md +87 -87
  79. package/npm-docs/api/omdEquationNode.md +174 -174
  80. package/npm-docs/api/omdEquationSequenceNode.md +258 -258
  81. package/npm-docs/api/omdEquationStack.md +192 -192
  82. package/npm-docs/api/omdFunctionNode.md +82 -82
  83. package/npm-docs/api/omdGroupNode.md +78 -78
  84. package/npm-docs/api/omdHelpers.md +87 -87
  85. package/npm-docs/api/omdLeafNode.md +85 -85
  86. package/npm-docs/api/omdNode.md +201 -201
  87. package/npm-docs/api/omdOperationDisplayNode.md +117 -117
  88. package/npm-docs/api/omdOperatorNode.md +91 -91
  89. package/npm-docs/api/omdParenthesisNode.md +133 -133
  90. package/npm-docs/api/omdPopup.md +191 -191
  91. package/npm-docs/api/omdPowerNode.md +131 -131
  92. package/npm-docs/api/omdRationalNode.md +144 -144
  93. package/npm-docs/api/omdSequenceNode.md +128 -128
  94. package/npm-docs/api/omdSimplification.md +78 -78
  95. package/npm-docs/api/omdSqrtNode.md +144 -144
  96. package/npm-docs/api/omdStepVisualizer.md +146 -146
  97. package/npm-docs/api/omdStepVisualizerHighlighting.md +65 -65
  98. package/npm-docs/api/omdStepVisualizerInteractiveSteps.md +108 -108
  99. package/npm-docs/api/omdStepVisualizerLayout.md +70 -70
  100. package/npm-docs/api/omdStepVisualizerNodeUtils.md +140 -140
  101. package/npm-docs/api/omdStepVisualizerTextBoxes.md +76 -76
  102. package/npm-docs/api/omdToolbar.md +130 -130
  103. package/npm-docs/api/omdTranscriptionService.md +95 -95
  104. package/npm-docs/api/omdTreeDiff.md +169 -169
  105. package/npm-docs/api/omdUnaryExpressionNode.md +137 -137
  106. package/npm-docs/api/omdUtilities.md +82 -82
  107. package/npm-docs/api/omdVariableNode.md +123 -123
  108. package/npm-docs/api/selectTool.md +74 -74
  109. package/npm-docs/api/simplificationEngine.md +97 -97
  110. package/npm-docs/api/simplificationRules.md +76 -76
  111. package/npm-docs/api/simplificationUtils.md +64 -64
  112. package/npm-docs/api/transcribe.md +43 -43
  113. package/npm-docs/guides/equations.md +854 -854
  114. package/npm-docs/guides/factory-functions.md +354 -354
  115. package/npm-docs/guides/getting-started.md +318 -318
  116. package/npm-docs/guides/quick-examples.md +525 -525
  117. package/npm-docs/guides/visualizations.md +682 -682
  118. package/npm-docs/index.html +12 -0
  119. package/npm-docs/json-schemas.md +826 -826
  120. package/omd/config/omdConfigManager.js +279 -267
  121. package/omd/core/index.js +158 -158
  122. package/omd/core/omdEquationStack.js +606 -547
  123. package/omd/core/omdUtilities.js +113 -113
  124. package/omd/display/omdDisplay.js +1045 -963
  125. package/omd/display/omdToolbar.js +501 -501
  126. package/omd/nodes/omdBinaryExpressionNode.js +459 -459
  127. package/omd/nodes/omdConstantNode.js +141 -141
  128. package/omd/nodes/omdEquationNode.js +1327 -1327
  129. package/omd/nodes/omdFunctionNode.js +351 -351
  130. package/omd/nodes/omdGroupNode.js +67 -67
  131. package/omd/nodes/omdLeafNode.js +76 -76
  132. package/omd/nodes/omdNode.js +556 -556
  133. package/omd/nodes/omdOperationDisplayNode.js +321 -321
  134. package/omd/nodes/omdOperatorNode.js +108 -108
  135. package/omd/nodes/omdParenthesisNode.js +292 -292
  136. package/omd/nodes/omdPowerNode.js +235 -235
  137. package/omd/nodes/omdRationalNode.js +295 -295
  138. package/omd/nodes/omdSqrtNode.js +307 -307
  139. package/omd/nodes/omdUnaryExpressionNode.js +227 -227
  140. package/omd/nodes/omdVariableNode.js +122 -122
  141. package/omd/simplification/omdSimplification.js +140 -140
  142. package/omd/simplification/omdSimplificationEngine.js +887 -887
  143. package/omd/simplification/package.json +5 -5
  144. package/omd/simplification/rules/binaryRules.js +1037 -1037
  145. package/omd/simplification/rules/functionRules.js +111 -111
  146. package/omd/simplification/rules/index.js +48 -48
  147. package/omd/simplification/rules/parenthesisRules.js +19 -19
  148. package/omd/simplification/rules/powerRules.js +143 -143
  149. package/omd/simplification/rules/rationalRules.js +725 -725
  150. package/omd/simplification/rules/sqrtRules.js +48 -48
  151. package/omd/simplification/rules/unaryRules.js +37 -37
  152. package/omd/simplification/simplificationRules.js +31 -31
  153. package/omd/simplification/simplificationUtils.js +1055 -1055
  154. package/omd/step-visualizer/omdStepVisualizer.js +947 -947
  155. package/omd/step-visualizer/omdStepVisualizerHighlighting.js +246 -246
  156. package/omd/step-visualizer/omdStepVisualizerLayout.js +892 -892
  157. package/omd/step-visualizer/omdStepVisualizerTextBoxes.js +200 -200
  158. package/omd/utils/aiNextEquationStep.js +106 -106
  159. package/omd/utils/omdNodeOverlay.js +638 -638
  160. package/omd/utils/omdPopup.js +1203 -1203
  161. package/omd/utils/omdStepVisualizerInteractiveSteps.js +684 -684
  162. package/omd/utils/omdStepVisualizerNodeUtils.js +267 -267
  163. package/omd/utils/omdTranscriptionService.js +123 -123
  164. package/omd/utils/omdTreeDiff.js +733 -733
  165. package/package.json +59 -57
  166. package/readme.html +184 -120
  167. package/src/index.js +74 -74
  168. package/src/json-schemas.md +576 -576
  169. package/src/omd-json-samples.js +147 -147
  170. package/src/omdApp.js +391 -391
  171. package/src/omdAppCanvas.js +335 -335
  172. package/src/omdBalanceHanger.js +199 -199
  173. package/src/omdColor.js +13 -13
  174. package/src/omdCoordinatePlane.js +541 -541
  175. package/src/omdExpression.js +115 -115
  176. package/src/omdFactory.js +150 -150
  177. package/src/omdFunction.js +114 -114
  178. package/src/omdMetaExpression.js +290 -290
  179. package/src/omdNaturalExpression.js +563 -563
  180. package/src/omdNode.js +383 -383
  181. package/src/omdNumber.js +52 -52
  182. package/src/omdNumberLine.js +114 -112
  183. package/src/omdNumberTile.js +118 -118
  184. package/src/omdOperator.js +72 -72
  185. package/src/omdPowerExpression.js +91 -91
  186. package/src/omdProblem.js +259 -259
  187. package/src/omdRatioChart.js +251 -251
  188. package/src/omdRationalExpression.js +114 -114
  189. package/src/omdSampleData.js +215 -215
  190. package/src/omdShapes.js +512 -512
  191. package/src/omdSpinner.js +151 -151
  192. package/src/omdString.js +49 -49
  193. package/src/omdTable.js +498 -498
  194. package/src/omdTapeDiagram.js +244 -244
  195. package/src/omdTerm.js +91 -91
  196. package/src/omdTileEquation.js +349 -349
  197. package/src/omdUtils.js +84 -84
  198. package/src/omdVariable.js +51 -51
@@ -1,1037 +1,1037 @@
1
- import { SimplificationEngine } from '../omdSimplificationEngine.js';
2
- import * as utils from '../simplificationUtils.js';
3
- import { getMultiplicationSymbol } from '../../config/omdConfigManager.js';
4
- import { omdRationalNode } from '../../nodes/omdRationalNode.js';
5
-
6
- // ===== BINARY EXPRESSION RULES =====
7
- export const binaryRules = [
8
- // Handle addition cases like a + (-a) = 0
9
- SimplificationEngine.createRule("Opposite Term Cancellation",
10
- (node) => {
11
- // Only handle addition for now
12
- if (!SimplificationEngine.isBinaryOp(node, 'add')) {
13
- return false;
14
- }
15
-
16
- // Check for constant + (-constant) patterns (2 + (-2) = 0)
17
- if (node.left.isConstant()) {
18
- const leftVal = node.left.getValue();
19
-
20
- // Check if right side is a unary minus of a constant
21
- if (SimplificationEngine.isType(node.right, 'omdUnaryExpressionNode') &&
22
- node.right.operation === 'unaryMinus' &&
23
- node.right.argument.isConstant()) {
24
- const rightVal = node.right.argument.getValue();
25
- if (leftVal === rightVal) {
26
- return {
27
- leftTerm: node.left,
28
- rightTerm: node.right,
29
- termType: 'constant',
30
- leftValue: leftVal,
31
- rightValue: -rightVal,
32
- isNegatedRight: true
33
- };
34
- }
35
- }
36
-
37
- // Check for direct opposite constants (rare case)
38
- if (node.right.isConstant()) {
39
- const rightVal = node.right.getValue();
40
- if (leftVal === -rightVal) {
41
- return {
42
- leftTerm: node.left,
43
- rightTerm: node.right,
44
- termType: 'constant',
45
- leftValue: leftVal,
46
- rightValue: rightVal,
47
- isNegatedRight: false
48
- };
49
- }
50
- }
51
- }
52
-
53
- // Check if we have a + (-a) pattern for monomials
54
- const leftMonomial = SimplificationEngine.isMonomial(node.left);
55
- let rightMonomial = null;
56
- let isNegatedRight = false;
57
-
58
- // Check if right side is a unary minus
59
- if (SimplificationEngine.isType(node.right, 'omdUnaryExpressionNode') &&
60
- node.right.operation === 'unaryMinus') {
61
- rightMonomial = SimplificationEngine.isMonomial(node.right.argument);
62
- isNegatedRight = true;
63
- } else {
64
- rightMonomial = SimplificationEngine.isMonomial(node.right);
65
- }
66
-
67
- if (leftMonomial && rightMonomial) {
68
- // For a + (-a), check if left coefficient equals right coefficient
69
- // For a + (-2a), check if left coefficient equals negative of right coefficient
70
- let leftCoeff = leftMonomial.coefficient;
71
- let rightCoeff = rightMonomial.coefficient;
72
-
73
- if (isNegatedRight) {
74
- rightCoeff = -rightCoeff; // Since it's wrapped in unary minus
75
- }
76
-
77
- // Check if they are the same variable with same power and opposite coefficients
78
- if (leftMonomial.variable === rightMonomial.variable &&
79
- leftMonomial.power === rightMonomial.power &&
80
- leftCoeff === -rightCoeff) {
81
- return {
82
- leftTerm: node.left,
83
- rightTerm: node.right,
84
- termType: 'monomial',
85
- variable: leftMonomial.variable,
86
- power: leftMonomial.power,
87
- leftCoeff: leftCoeff,
88
- rightCoeff: rightCoeff,
89
- isNegatedRight: isNegatedRight
90
- };
91
- }
92
- }
93
-
94
- return false;
95
- },
96
- (node, data) => {
97
- const { leftTerm, rightTerm } = data;
98
- const zeroNode = SimplificationEngine.createConstant(0, node.getFontSize());
99
-
100
- // Preserve provenance from both terms
101
- zeroNode.provenance.push(leftTerm.id);
102
- zeroNode.provenance.push(rightTerm.id);
103
- zeroNode.provenance.push(node.id);
104
-
105
- return zeroNode;
106
- },
107
- (originalNode, ruleData, newNode) => {
108
- const { termType } = ruleData;
109
-
110
- if (termType === 'constant') {
111
- const { leftValue, rightValue, isNegatedRight } = ruleData;
112
- if (isNegatedRight) {
113
- return `Cancelled opposite terms: ${leftValue} + (-${leftValue}) = 0`;
114
- } else {
115
- return `Cancelled opposite terms: ${leftValue} + ${rightValue} = 0`;
116
- }
117
- } else {
118
- const { variable, power, leftCoeff, rightCoeff, isNegatedRight } = ruleData;
119
- const powerStr = power !== 1 ? `^${power}` : '';
120
-
121
- // Format the left term
122
- const leftCoeffStr = leftCoeff === 1 ? '' : leftCoeff === -1 ? '-' : `${leftCoeff}`;
123
- const leftTermStr = `${leftCoeffStr}${variable}${powerStr}`;
124
-
125
- // Format the right term
126
- let rightTermStr;
127
- if (isNegatedRight) {
128
- const innerCoeff = Math.abs(rightCoeff);
129
- const innerCoeffStr = innerCoeff === 1 ? '' : `${innerCoeff}`;
130
- rightTermStr = `(-${innerCoeffStr}${variable}${powerStr})`;
131
- } else {
132
- const rightCoeffStr = rightCoeff === 1 ? '' : rightCoeff === -1 ? '-' : `${rightCoeff}`;
133
- rightTermStr = `${rightCoeffStr}${variable}${powerStr}`;
134
- }
135
-
136
- return `Cancelled opposite terms: ${leftTermStr} + ${rightTermStr} = 0`;
137
- }
138
- }
139
- ),
140
-
141
- // Handle constant cancellation in multi-term sums (3x + 2 - 2 → 3x + 0)
142
- SimplificationEngine.createRule("Cancel Constants in Sums",
143
- (node) => {
144
- if (!SimplificationEngine.isBinaryOp(node) ||
145
- (node.operation !== 'add' && node.operation !== 'subtract')) return false;
146
-
147
- // Flatten the sum to get all terms
148
- const terms = [];
149
- utils.flattenSum(node, terms);
150
-
151
- // Only proceed if we have at least 3 terms (need non-constants + constants that cancel)
152
- if (terms.length < 3) return false;
153
-
154
- const constantTerms = terms.filter(t => t.node.isConstant());
155
- const nonConstantTerms = terms.filter(t => !t.node.isConstant());
156
-
157
- // Need at least one non-constant term and at least 2 constants
158
- if (nonConstantTerms.length === 0 || constantTerms.length < 2) return false;
159
-
160
- // Check if any constants cancel out exactly
161
- const cancellingPairs = [];
162
- const usedIndices = new Set();
163
-
164
- for (let i = 0; i < constantTerms.length; i++) {
165
- if (usedIndices.has(i)) continue;
166
-
167
- const term1 = constantTerms[i];
168
- const val1 = term1.node.getValue() * term1.sign;
169
-
170
- for (let j = i + 1; j < constantTerms.length; j++) {
171
- if (usedIndices.has(j)) continue;
172
-
173
- const term2 = constantTerms[j];
174
- const val2 = term2.node.getValue() * term2.sign;
175
-
176
- // Check if they cancel exactly
177
- if (val1 + val2 === 0) {
178
- cancellingPairs.push([term1, term2]);
179
- usedIndices.add(i);
180
- usedIndices.add(j);
181
- break;
182
- }
183
- }
184
- }
185
-
186
- if (cancellingPairs.length > 0) {
187
- return {
188
- terms: terms,
189
- cancellingPairs: cancellingPairs,
190
- constantTerms: constantTerms,
191
- nonConstantTerms: nonConstantTerms
192
- };
193
- }
194
-
195
- return false;
196
- },
197
- (node, data) => {
198
- const { terms, cancellingPairs, constantTerms, nonConstantTerms } = data;
199
-
200
- // Start with non-constant terms
201
- const finalTerms = [...nonConstantTerms];
202
-
203
- // Keep track of which constant terms were cancelled
204
- const cancelledTerms = new Set();
205
- cancellingPairs.forEach(pair => {
206
- pair.forEach(term => cancelledTerms.add(term));
207
- });
208
-
209
- // Add back any constant terms that weren't cancelled
210
- constantTerms.forEach(term => {
211
- if (!cancelledTerms.has(term)) {
212
- finalTerms.push(term);
213
- }
214
- });
215
-
216
- // Collect detailed provenance from all cancelled terms
217
- let cancelledNodeIds = [];
218
- if (cancellingPairs.length > 0) {
219
- const zeroNode = SimplificationEngine.createConstant(0, node.getFontSize());
220
-
221
- // Add provenance from all cancelled terms and their operators
222
- cancellingPairs.forEach(pair => {
223
- // Find the operator node between the pair
224
- const [term1, term2] = pair;
225
- const parent = terms.find(t =>
226
- t.node.type === 'omdBinaryExpressionNode' &&
227
- ((t.node.left === term1.node && t.node.right === term2.node) ||
228
- (t.node.left === term2.node && t.node.right === term1.node))
229
- );
230
-
231
- // Add provenance from the constants and the operator
232
- pair.forEach(term => {
233
- zeroNode.provenance.push(term.node.id);
234
- cancelledNodeIds.push(term.node.id);
235
-
236
- // If this term has an operator (e.g. the minus sign), include it
237
- if (term.node.operation) {
238
- zeroNode.provenance.push(term.node.operation.id);
239
- cancelledNodeIds.push(term.node.operation.id);
240
- }
241
- });
242
-
243
- // Add provenance from the binary operation's operator
244
- if (parent && parent.node.operation) {
245
- zeroNode.provenance.push(parent.node.operation.id);
246
- cancelledNodeIds.push(parent.node.operation.id);
247
- }
248
- });
249
-
250
- finalTerms.push({ node: zeroNode, sign: 1 });
251
- }
252
-
253
- // Build the result tree
254
- const result = utils.buildSumTree(finalTerms, node.getFontSize());
255
-
256
- if (result) {
257
- // Only preserve provenance from the cancelled terms
258
- cancelledNodeIds.forEach(id => {
259
- if (!result.provenance.includes(id)) {
260
- result.provenance.push(id);
261
- }
262
- });
263
- }
264
-
265
- return result;
266
- },
267
- (originalNode, ruleData, newNode) => {
268
- const { cancellingPairs } = ruleData;
269
-
270
- const cancellationDescriptions = cancellingPairs.map(pair => {
271
- const [term1, term2] = pair;
272
- const val1 = term1.node.getValue();
273
- const val2 = term2.node.getValue();
274
- const sign1 = term1.sign === 1 ? '+' : '-';
275
- const sign2 = term2.sign === 1 ? '+' : '-';
276
-
277
- return `${sign1} ${val1} ${sign2} ${val2} = 0`;
278
- });
279
-
280
- return `Cancelled constants in sum: ${cancellationDescriptions.join(', ')}`;
281
- }
282
- ),
283
-
284
- // Basic constant folding (works for both regular and rational constants)
285
- SimplificationEngine.createConstantFoldRule("Add Constants", "add"),
286
- SimplificationEngine.createConstantFoldRule("Subtract Constants", "subtract"),
287
- SimplificationEngine.createConstantFoldRule("Multiply Constants", "multiply"),
288
- SimplificationEngine.createConstantFoldRule("Divide Constants", "divide"),
289
-
290
- // Identity operations
291
- SimplificationEngine.createIdentityRule("Add Zero", "add", 0), // x + 0 → x, 0 + x → x
292
- SimplificationEngine.createIdentityRule("Subtract Zero", "subtract", 0, 'right'), // x - 0 → x
293
- SimplificationEngine.createIdentityRule("Multiply One", "multiply", 1), // x * 1 → x, 1 * x → x
294
- SimplificationEngine.createIdentityRule("Divide One", "divide", 1, 'right'), // x / 1 → x
295
-
296
- // Zero multiplication (anything times zero equals zero)
297
- SimplificationEngine.createZeroMultiplicationRule(),
298
-
299
- // Coefficient multiplication (2 * 3x → 6x)
300
- SimplificationEngine.createRule("Combine Coefficients",
301
- (node) => {
302
- if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
303
-
304
- const constOp = SimplificationEngine.hasConstantOperand(node);
305
- if (!constOp) return false;
306
-
307
- const otherNode = constOp.other;
308
- if (!SimplificationEngine.isBinaryOp(otherNode, 'multiply')) return false;
309
-
310
- const innerConstOp = SimplificationEngine.hasConstantOperand(otherNode);
311
- if (!innerConstOp) return false;
312
-
313
- const outerConstant = constOp.constant.getValue();
314
- const innerConstant = innerConstOp.constant.getValue();
315
- const expression = innerConstOp.other;
316
-
317
- return {
318
- coefficient: outerConstant * innerConstant,
319
- expression
320
- };
321
- },
322
- (node, data) => {
323
- const { coefficient, expression } = data;
324
- const newNode = SimplificationEngine.createMultiplication(
325
- SimplificationEngine.createConstant(coefficient, node.getFontSize()),
326
- expression.clone(),
327
- node.getFontSize()
328
- );
329
-
330
- // Preserve provenance from both operands
331
- newNode.provenance.push(node.id);
332
- if (newNode.left) {
333
- newNode.left.provenance.push(node.left.id, node.right.id);
334
- }
335
- if (newNode.right) {
336
- newNode.right.provenance.push(expression.id);
337
- }
338
-
339
- return newNode;
340
- },
341
- (originalNode, ruleData, newNode) => {
342
- const { coefficient, expression } = ruleData;
343
- const constOp = SimplificationEngine.hasConstantOperand(originalNode);
344
- const innerConstOp = SimplificationEngine.hasConstantOperand(constOp.other);
345
- const outerVal = constOp.constant.getValue();
346
- const innerVal = innerConstOp.constant.getValue();
347
-
348
- return `Combined coefficients: ${outerVal} ${getMultiplicationSymbol()} ${innerVal} = ${coefficient}`;
349
- }
350
- ),
351
-
352
- // Distributive property (2*(x+3) → 2x + 6)
353
- SimplificationEngine.createRule("Distributive Property",
354
- (node) => {
355
- if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
356
-
357
- const constOp = SimplificationEngine.hasConstantOperand(node);
358
- if (!constOp) return false;
359
-
360
- const otherNode = constOp.other;
361
-
362
- // Check if the other operand is a parenthesized sum/difference
363
- let innerExpr = otherNode;
364
- if (SimplificationEngine.isType(otherNode, 'omdParenthesisNode')) {
365
- innerExpr = otherNode.expression;
366
- }
367
-
368
- if (!SimplificationEngine.isBinaryOp(innerExpr, 'add') &&
369
- !SimplificationEngine.isBinaryOp(innerExpr, 'subtract')) {
370
- return false;
371
- }
372
-
373
- return {
374
- constantNode: constOp.constant,
375
- innerExpr: innerExpr,
376
- originalInnerNode: otherNode
377
- };
378
- },
379
- (node, data) => {
380
- const { constantNode, innerExpr } = data;
381
- const multiplier = constantNode.getValue();
382
- const fontSize = node.getFontSize();
383
-
384
- // Distribute the constant across the terms
385
- const terms = [];
386
- utils.flattenSum(innerExpr, terms);
387
-
388
- const distributedTerms = terms.map(term => {
389
- const newCoeff = multiplier * term.sign;
390
- const distributedNode = SimplificationEngine.createBinaryOp(
391
- SimplificationEngine.createConstant(Math.abs(newCoeff), fontSize),
392
- 'multiply',
393
- term.node.clone(),
394
- fontSize
395
- );
396
-
397
- // Preserve provenance
398
- distributedNode.provenance.push(node.id, constantNode.id, term.node.id);
399
-
400
- return {
401
- node: distributedNode,
402
- sign: newCoeff >= 0 ? 1 : -1
403
- };
404
- });
405
-
406
- return utils.buildSumTree(distributedTerms, fontSize);
407
- },
408
- (originalNode, ruleData, newNode) => {
409
- const { constantNode, innerExpr } = ruleData;
410
- const multiplierStr = constantNode.toString();
411
- const expressionStr = innerExpr.toString();
412
-
413
- return `Applied distributive property: ${multiplierStr} ${getMultiplicationSymbol()} (${expressionStr})`;
414
- }
415
- ),
416
-
417
- // Expand polynomial multiplication like (3x+3)(2x+2) using FOIL/distributive property
418
- SimplificationEngine.createRule("Expand Polynomial Multiplication",
419
- (node) => {
420
- if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
421
-
422
- // Both operands should be sums or differences (optionally parenthesized)
423
- let leftExpr = SimplificationEngine.unwrapParentheses(node.left);
424
- let rightExpr = SimplificationEngine.unwrapParentheses(node.right);
425
-
426
- // Check if both sides are sums or differences
427
- const leftIsSum = SimplificationEngine.isBinaryOp(leftExpr, 'add') ||
428
- SimplificationEngine.isBinaryOp(leftExpr, 'subtract');
429
- const rightIsSum = SimplificationEngine.isBinaryOp(rightExpr, 'add') ||
430
- SimplificationEngine.isBinaryOp(rightExpr, 'subtract');
431
-
432
- if (!leftIsSum || !rightIsSum) return false;
433
-
434
- // Extract terms from both expressions
435
- const leftTerms = [];
436
- const rightTerms = [];
437
- utils.flattenSum(leftExpr, leftTerms);
438
- utils.flattenSum(rightExpr, rightTerms);
439
-
440
- // Limit to reasonable number of terms to avoid explosion
441
- if (leftTerms.length > 4 || rightTerms.length > 4) return false;
442
-
443
- return {
444
- leftExpression: leftExpr,
445
- rightExpression: rightExpr,
446
- leftTerms: leftTerms,
447
- rightTerms: rightTerms
448
- };
449
- },
450
- (node, data) => {
451
- const { leftTerms, rightTerms } = data;
452
- const fontSize = node.getFontSize();
453
-
454
- // Apply distributive property: each term in left multiplied by each term in right
455
- const expandedTerms = [];
456
-
457
- for (const leftTerm of leftTerms) {
458
- for (const rightTerm of rightTerms) {
459
- const productSign = leftTerm.sign * rightTerm.sign;
460
- const productNode = SimplificationEngine.createBinaryOp(
461
- leftTerm.node.clone(),
462
- 'multiply',
463
- rightTerm.node.clone(),
464
- fontSize
465
- );
466
-
467
- // Use granular provenance
468
- const allLeafNodes = [...(leftTerm.leafNodes || []), ...(rightTerm.leafNodes || [])];
469
- allLeafNodes.forEach(leafNode => {
470
- [productNode.left, productNode.right, productNode].forEach(part => {
471
- if (part && !part.provenance.includes(leafNode.id)) {
472
- part.provenance.push(leafNode.id);
473
- }
474
- });
475
- });
476
-
477
- expandedTerms.push({
478
- node: productNode,
479
- sign: productSign,
480
- leafNodes: allLeafNodes
481
- });
482
- }
483
- }
484
-
485
- return utils.buildSumTree(expandedTerms, fontSize);
486
- },
487
- (originalNode, ruleData, newNode) => {
488
- const { leftExpression, rightExpression } = ruleData;
489
- const leftStr = utils.nodeToString(leftExpression);
490
- const rightStr = utils.nodeToString(rightExpression);
491
- return `Expanded polynomial multiplication: (${leftStr})(${rightStr})`;
492
- }
493
- ),
494
-
495
- // Multiply monomials (2x * 3x -> 6x^2, 3y^2 * 4y -> 12y^3)
496
- SimplificationEngine.createRule("Multiply Monomials",
497
- (node) => {
498
- if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
499
-
500
- // Check if both operands are monomials
501
- const leftMonomial = SimplificationEngine.isMonomial(node.left);
502
- const rightMonomial = SimplificationEngine.isMonomial(node.right);
503
-
504
- if (!leftMonomial || !rightMonomial) return false;
505
-
506
- // Check if they have the same variable
507
- if (leftMonomial.variable !== rightMonomial.variable) return false;
508
-
509
- return {
510
- variable: leftMonomial.variable,
511
- leftCoeff: leftMonomial.coefficient,
512
- rightCoeff: rightMonomial.coefficient,
513
- leftPower: leftMonomial.power,
514
- rightPower: rightMonomial.power,
515
- leftNode: node.left,
516
- rightNode: node.right
517
- };
518
- },
519
- (node, data) => {
520
- const { variable, leftCoeff, rightCoeff, leftPower, rightPower, leftNode, rightNode } = data;
521
- const fontSize = node.getFontSize();
522
-
523
- // Calculate new coefficient and power
524
- const newCoeff = leftCoeff * rightCoeff;
525
- const newPower = leftPower + rightPower;
526
-
527
- // Extract granular provenance
528
- const leftProvenance = utils.extractMonomialProvenance(leftNode);
529
- const rightProvenance = utils.extractMonomialProvenance(rightNode);
530
-
531
- const coefficientProvenance = [
532
- ...leftProvenance.coefficientNodes.map(n => n.id),
533
- ...rightProvenance.coefficientNodes.map(n => n.id)
534
- ];
535
-
536
- const variableProvenance = [
537
- ...leftProvenance.variableNodes.map(n => n.id),
538
- ...rightProvenance.variableNodes.map(n => n.id)
539
- ];
540
-
541
- // Create the result with granular provenance
542
- const result = utils.createMonomialWithGranularProvenance(
543
- newCoeff,
544
- variable,
545
- newPower,
546
- fontSize,
547
- coefficientProvenance,
548
- variableProvenance
549
- );
550
-
551
- result.provenance.push(node.id);
552
-
553
- return result;
554
- },
555
- (originalNode, ruleData, newNode) => {
556
- const { leftCoeff, rightCoeff, variable, leftPower, rightPower } = ruleData;
557
- const newCoeff = leftCoeff * rightCoeff;
558
- const newPower = leftPower + rightPower;
559
- const leftStr = utils.nodeToString(ruleData.leftNode);
560
- const rightStr = utils.nodeToString(ruleData.rightNode);
561
-
562
- return `Multiplied monomials: ${leftStr} ${getMultiplicationSymbol()} ${rightStr} = ${newCoeff}${variable}${newPower > 1 ? `^${newPower}` : ''}`;
563
- }
564
- ),
565
-
566
- // Combine identical terms in multiplication to create powers (x*x -> x^2, y*y*y -> y^3)
567
- SimplificationEngine.createRule("Combine Like Factors",
568
- (node) => {
569
- if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
570
-
571
- // Check if both operands are identical variables or simple expressions
572
- if (SimplificationEngine.isType(node.left, 'omdVariableNode') &&
573
- SimplificationEngine.isType(node.right, 'omdVariableNode') &&
574
- node.left.name === node.right.name) {
575
- return {
576
- variable: node.left.name,
577
- leftNode: node.left,
578
- rightNode: node.right,
579
- power: 2
580
- };
581
- }
582
-
583
- // Check for more complex patterns like x^2 * x -> x^3
584
- let baseVar = null;
585
- let leftPower = 1;
586
- let rightPower = 1;
587
- let leftNode = node.left;
588
- let rightNode = node.right;
589
-
590
- // Analyze left operand
591
- if (SimplificationEngine.isType(node.left, 'omdVariableNode')) {
592
- baseVar = node.left.name;
593
- leftPower = 1;
594
- } else if (SimplificationEngine.isType(node.left, 'omdPowerNode') &&
595
- SimplificationEngine.isType(node.left.base, 'omdVariableNode') &&
596
- node.left.exponent.isConstant()) {
597
- baseVar = node.left.base.name;
598
- leftPower = node.left.exponent.getValue();
599
- }
600
-
601
- // Analyze right operand
602
- if (SimplificationEngine.isType(node.right, 'omdVariableNode')) {
603
- if (baseVar === node.right.name) {
604
- rightPower = 1;
605
- } else {
606
- return false;
607
- }
608
- } else if (SimplificationEngine.isType(node.right, 'omdPowerNode') &&
609
- SimplificationEngine.isType(node.right.base, 'omdVariableNode') &&
610
- node.right.exponent.isConstant()) {
611
- if (baseVar === node.right.base.name) {
612
- rightPower = node.right.exponent.getValue();
613
- } else {
614
- return false;
615
- }
616
- } else {
617
- return false;
618
- }
619
-
620
- if (baseVar && Number.isInteger(leftPower) && Number.isInteger(rightPower)) {
621
- return {
622
- variable: baseVar,
623
- leftNode: leftNode,
624
- rightNode: rightNode,
625
- power: leftPower + rightPower
626
- };
627
- }
628
-
629
- return false;
630
- },
631
- (node, data) => {
632
- const { variable, power, leftNode, rightNode } = data;
633
- const fontSize = node.getFontSize();
634
-
635
- let result;
636
- if (power === 1) {
637
- result = SimplificationEngine.createMonomial(1, variable, 1, fontSize);
638
- } else {
639
- const variableNode = SimplificationEngine.createMonomial(1, variable, 1, fontSize);
640
- result = utils.createPowerTerm(variableNode, power, fontSize);
641
- }
642
-
643
- // Preserve provenance from both original factors
644
- result.provenance.push(leftNode.id, rightNode.id, node.id);
645
-
646
- return result;
647
- },
648
- (originalNode, ruleData, newNode) => {
649
- const { variable, power } = ruleData;
650
- const leftStr = utils.nodeToString(ruleData.leftNode);
651
- const rightStr = utils.nodeToString(ruleData.rightNode);
652
-
653
- return `Combined like factors: ${leftStr} ${getMultiplicationSymbol()} ${rightStr} = ${variable}${power > 1 ? `^${power}` : ''}`;
654
- }
655
- ),
656
-
657
- // Complex sum folding (x + 2 + 3 → x + 5)
658
- SimplificationEngine.createRule("Combine Multiple Constants in Sums",
659
- (node) => {
660
- if (!SimplificationEngine.isBinaryOp(node) ||
661
- (node.operation !== 'add' && node.operation !== 'subtract')) return false;
662
-
663
- // Flatten the sum
664
- const terms = [];
665
- utils.flattenSum(node, terms);
666
-
667
- const constantCount = terms.filter(t => t.node.isConstant()).length;
668
- if (constantCount <= 1) return false;
669
-
670
- // Calculate what the combined constant would be
671
- const constantTerms = terms.filter(t => t.node.isConstant());
672
- let totalNum = 0, totalDen = 1;
673
- for (const term of constantTerms) {
674
- const { num, den } = term.node.getRationalValue();
675
- const newTotalNum = (totalNum * den) + (num * term.sign * totalDen);
676
- const newTotalDen = totalDen * den;
677
- totalNum = newTotalNum;
678
- totalDen = newTotalDen;
679
- const commonDivisor = utils.gcd(Math.abs(totalNum), Math.abs(totalDen));
680
- totalNum /= commonDivisor;
681
- totalDen /= commonDivisor;
682
- }
683
-
684
- // Don't handle cases where the result would be zero - let cancellation rules handle those
685
- if (totalNum === 0) return false;
686
-
687
- return { terms };
688
- },
689
- (node, data) => {
690
- const { terms } = data;
691
-
692
- const constantTerms = terms.filter(t => t.node.isConstant());
693
- const otherTerms = terms.filter(t => !t.node.isConstant());
694
-
695
- // Get the actual constant nodes for provenance
696
- const constantNodes = constantTerms.map(t => t.node);
697
-
698
- // Combine all constants using rational arithmetic
699
- let totalNum = 0, totalDen = 1;
700
- for (const term of constantTerms) {
701
- const { num, den } = term.node.getRationalValue();
702
- const newTotalNum = (totalNum * den) + (num * term.sign * totalDen);
703
- const newTotalDen = totalDen * den;
704
- totalNum = newTotalNum;
705
- totalDen = newTotalDen;
706
- const commonDivisor = utils.gcd(Math.abs(totalNum), Math.abs(totalDen));
707
- totalNum /= commonDivisor;
708
- totalDen /= commonDivisor;
709
- }
710
-
711
- // Add combined constant back to terms if non-zero
712
- let finalTerms = [...otherTerms];
713
-
714
- if (totalNum !== 0 || finalTerms.length === 0) {
715
- if (totalDen < 0) { totalNum = -totalNum; totalDen = -totalDen; }
716
- const sign = totalNum >= 0 ? 1 : -1;
717
- const absNum = Math.abs(totalNum);
718
-
719
- let newNode;
720
- if (totalDen === 1) {
721
- // Create a simple constant with automatic provenance tracking
722
- newNode = SimplificationEngine.createConstant(absNum, node.getFontSize(), ...constantNodes);
723
- } else {
724
- // Create a rational node
725
- newNode = new omdRationalNode({
726
- type: 'OperatorNode',
727
- fn: 'divide',
728
- args: [
729
- { type: 'ConstantNode', value: absNum },
730
- { type: 'ConstantNode', value: totalDen }
731
- ]
732
- });
733
- newNode.setFontSize(node.getFontSize());
734
- // Apply provenance for rational nodes manually using the same approach
735
- constantNodes.forEach(sourceNode => {
736
- if (sourceNode.id && !newNode.provenance.includes(sourceNode.id)) {
737
- newNode.provenance.push(sourceNode.id);
738
- }
739
- });
740
- }
741
-
742
- finalTerms.push({ node: newNode, sign: sign });
743
- }
744
-
745
- if (finalTerms.length === 0) {
746
- const zeroNode = SimplificationEngine.createConstant(0, node.getFontSize(), ...constantNodes);
747
- return zeroNode;
748
- }
749
-
750
- const result = utils.buildSumTree(finalTerms, node.getFontSize());
751
-
752
- if(result) {
753
- constantNodes.forEach(sourceNode => {
754
- if (sourceNode.id && !result.provenance.includes(sourceNode.id)) {
755
- result.provenance.push(sourceNode.id);
756
- }
757
- });
758
- result.provenance.push(node.id);
759
- }
760
-
761
- return result;
762
- },
763
- (originalNode, ruleData, newNode) => {
764
- const { terms } = ruleData;
765
- const constantTerms = terms.filter(t => t.node.isConstant());
766
-
767
- // Calculate the combined value
768
- let totalNum = 0, totalDen = 1;
769
- for (const term of constantTerms) {
770
- const { num, den } = term.node.getRationalValue();
771
- const newTotalNum = (totalNum * den) + (num * term.sign * totalDen);
772
- const newTotalDen = totalDen * den;
773
- totalNum = newTotalNum;
774
- totalDen = newTotalDen;
775
- const commonDivisor = utils.gcd(Math.abs(totalNum), Math.abs(totalDen));
776
- totalNum /= commonDivisor;
777
- totalDen /= commonDivisor;
778
- }
779
-
780
- const constantStrings = constantTerms.map(t => {
781
- const valueStr = utils.nodeToString(t.node);
782
- return t.sign === 1 ? `+ ${valueStr}` : `- ${valueStr}`;
783
- });
784
- // For the first term, if it's positive, remove the leading "+ ".
785
- if (constantStrings.length > 0 && constantStrings[0].startsWith('+ ')) {
786
- constantStrings[0] = constantStrings[0].substring(2);
787
- }
788
-
789
- const calculation = constantStrings.join(' ');
790
- const resultStr = totalDen === 1 ? `${totalNum}` : `${totalNum}/${totalDen}`;
791
-
792
- let message = `Combining the constant terms: ${calculation} = ${resultStr}. `;
793
-
794
- if (totalNum === 0) {
795
- message += `Since the constants sum to zero, they are replaced by 0.`;
796
- } else {
797
- message += `The constants are replaced by their sum.`;
798
- }
799
-
800
- return message;
801
- }
802
- ),
803
-
804
- // Combine like terms (monomials with same variable and power)
805
- SimplificationEngine.createRule("Combine Like Terms",
806
- (node) => {
807
- if (!SimplificationEngine.isBinaryOp(node, 'add') && !SimplificationEngine.isBinaryOp(node, 'subtract')) {
808
- return false;
809
- }
810
-
811
- const terms = [];
812
- utils.flattenSum(node, terms);
813
-
814
- const likeTermGroups = new Map();
815
- const otherTerms = [];
816
-
817
- for (const term of terms) {
818
- const monomialInfo = SimplificationEngine.isMonomial(term.node);
819
-
820
- if (monomialInfo) {
821
- const key = `${monomialInfo.variable}^${monomialInfo.power}`;
822
- if (!likeTermGroups.has(key)) {
823
- likeTermGroups.set(key, []);
824
- }
825
-
826
- likeTermGroups.get(key).push({
827
- term,
828
- monomialInfo,
829
- originalNodeId: term.node.id
830
- });
831
- } else {
832
- otherTerms.push(term);
833
- }
834
- }
835
-
836
- // Check if we have like terms to combine
837
- const foundLikeTerms = Array.from(likeTermGroups.values()).some(group => group.length > 1);
838
-
839
- return foundLikeTerms ? { likeTermGroups, otherTerms } : false;
840
- },
841
- (node, data) => {
842
- const { likeTermGroups, otherTerms } = data;
843
- // Start with a copy of the original terms array (to preserve order)
844
- let allNewTerms = [...otherTerms];
845
- // We'll build a map from node id to its index in the original terms array
846
- const originalTerms = [];
847
- utils.flattenSum(node, originalTerms);
848
-
849
- // For each group of like terms
850
- for (const [key, termGroup] of likeTermGroups) {
851
- if (termGroup.length === 1) {
852
- // Find the index of this term in the original terms array
853
- const idx = originalTerms.findIndex(t => t.node.id === termGroup[0].term.node.id);
854
- if (idx !== -1) {
855
- allNewTerms.splice(idx, 0, termGroup[0].term);
856
- } else {
857
- allNewTerms.push(termGroup[0].term);
858
- }
859
- } else {
860
- // Combine multiple like terms
861
- let totalCoeff = 0;
862
- const coefficientProvenance = [];
863
- const variableProvenance = [];
864
- const likeTermIds = termGroup.map(t => t.term.node.id);
865
- for (const termData of termGroup) {
866
- const coeff = termData.monomialInfo.coefficient * termData.term.sign;
867
- totalCoeff += coeff;
868
- // Extract granular provenance
869
- const monomialProvenance = utils.extractMonomialProvenance(termData.term.node);
870
- monomialProvenance.coefficientNodes.forEach(coeffNode => {
871
- if (!coefficientProvenance.includes(coeffNode.id)) {
872
- coefficientProvenance.push(coeffNode.id);
873
- }
874
- });
875
- monomialProvenance.variableNodes.forEach(varNode => {
876
- if (!variableProvenance.includes(varNode.id)) {
877
- variableProvenance.push(varNode.id);
878
- }
879
- });
880
- }
881
- // Create combined term if coefficient is non-zero
882
- if (totalCoeff !== 0) {
883
- const firstTerm = termGroup[0];
884
- const newMonomial = utils.createMonomialWithGranularProvenance(
885
- totalCoeff,
886
- firstTerm.monomialInfo.variable,
887
- firstTerm.monomialInfo.power,
888
- node.getFontSize(),
889
- coefficientProvenance,
890
- variableProvenance
891
- );
892
- // Find the leftmost index of any like term in the original terms array
893
- const leftmostIdx = originalTerms.findIndex(t => likeTermIds.includes(t.node.id));
894
- // Remove all like terms from allNewTerms (by node id)
895
- allNewTerms = allNewTerms.filter(t => !likeTermIds.includes(t.node.id));
896
- // Insert the new combined term at the leftmost index
897
- if (leftmostIdx !== -1) {
898
- allNewTerms.splice(leftmostIdx, 0, { node: newMonomial, sign: 1 });
899
- } else {
900
- allNewTerms.push({ node: newMonomial, sign: 1 });
901
- }
902
- } else {
903
- // If the sum is zero, just remove all like terms
904
- allNewTerms = allNewTerms.filter(t => !likeTermIds.includes(t.node.id));
905
- }
906
- }
907
- }
908
-
909
- // Handle zero case
910
- if (allNewTerms.length === 0) {
911
- return SimplificationEngine.createConstant(0, node.getFontSize());
912
- }
913
-
914
- return utils.buildSumTree(allNewTerms, node.getFontSize());
915
- },
916
- (originalNode, ruleData, newNode) => {
917
- const { likeTermGroups } = ruleData;
918
- const combinations = [];
919
-
920
- for (const [key, termGroup] of likeTermGroups) {
921
- if (termGroup.length > 1) {
922
- let totalCoeff = 0;
923
- const termDetails = [];
924
-
925
- for (const termData of termGroup) {
926
- const coeff = termData.monomialInfo.coefficient * termData.term.sign;
927
- totalCoeff += coeff;
928
-
929
- // Format individual terms for the explanation
930
- const variable = termData.monomialInfo.variable;
931
- const power = termData.monomialInfo.power;
932
- const powerStr = power !== 1 ? `^${power}` : '';
933
-
934
- if (coeff === 1) {
935
- termDetails.push(`${variable}${powerStr}`);
936
- } else if (coeff === -1) {
937
- termDetails.push(`-${variable}${powerStr}`);
938
- } else {
939
- termDetails.push(`${coeff}${variable}${powerStr}`);
940
- }
941
- }
942
-
943
- const variable = termGroup[0].monomialInfo.variable;
944
- const power = termGroup[0].monomialInfo.power;
945
- const powerStr = power !== 1 ? `^${power}` : '';
946
-
947
- if (totalCoeff === 0) {
948
- combinations.push(`${termDetails.join(' + ').replace('+ -', '- ')} = 0 (like terms cancelled)`);
949
- } else {
950
- const resultStr = totalCoeff === 1 ? `${variable}${powerStr}` :
951
- totalCoeff === -1 ? `-${variable}${powerStr}` :
952
- `${totalCoeff}${variable}${powerStr}`;
953
- combinations.push(`${termDetails.join(' + ').replace('+ -', '- ')} = ${resultStr}`);
954
- }
955
- }
956
- }
957
-
958
- if (combinations.length === 0) {
959
- return "No like terms were found to combine";
960
- } else if (combinations.length === 1) {
961
- return `Combined like terms: ${combinations[0]}`;
962
- } else {
963
- return `Combined like terms: ${combinations.join('; ')}`;
964
- }
965
- }
966
- ),
967
-
968
- // Multiply then divide by same factor: (a*x)/a → x or a*(x/a) → x
969
- SimplificationEngine.createRule("Multiply Divide Same Factor",
970
- (node) => {
971
- // Check for (a*x)/a pattern (rational node with multiplication in numerator)
972
- if (SimplificationEngine.isType(node, 'omdRationalNode')) {
973
- const numerator = SimplificationEngine.unwrapParentheses(node.numerator);
974
- const denominator = SimplificationEngine.unwrapParentheses(node.denominator);
975
-
976
- if (SimplificationEngine.isBinaryOp(numerator, 'multiply') &&
977
- SimplificationEngine.isType(denominator, 'omdConstantNode')) {
978
-
979
- const constOp = SimplificationEngine.hasConstantOperand(numerator);
980
- if (constOp && constOp.constant.getValue() === denominator.getValue()) {
981
- return {
982
- pattern: 'rational',
983
- factor: constOp.constant.getValue(),
984
- expression: constOp.other,
985
- factorNode: constOp.constant,
986
- denominatorNode: denominator
987
- };
988
- }
989
- }
990
- }
991
-
992
- // Check for a*(x/a) pattern (multiplication with rational)
993
- if (SimplificationEngine.isBinaryOp(node, 'multiply')) {
994
- const constOp = SimplificationEngine.hasConstantOperand(node);
995
- if (!constOp) return false;
996
-
997
- const otherNode = constOp.other;
998
- if (SimplificationEngine.isType(otherNode, 'omdRationalNode')) {
999
- const denominator = SimplificationEngine.unwrapParentheses(otherNode.denominator);
1000
-
1001
- if (SimplificationEngine.isType(denominator, 'omdConstantNode') &&
1002
- constOp.constant.getValue() === denominator.getValue()) {
1003
-
1004
- return {
1005
- pattern: 'multiply',
1006
- factor: constOp.constant.getValue(),
1007
- expression: SimplificationEngine.unwrapParentheses(otherNode.numerator),
1008
- factorNode: constOp.constant,
1009
- denominatorNode: denominator
1010
- };
1011
- }
1012
- }
1013
- }
1014
-
1015
- return false;
1016
- },
1017
- (node, data) => {
1018
- const { expression, factorNode, denominatorNode } = data;
1019
- const newNode = expression.clone();
1020
-
1021
- // Preserve provenance
1022
- newNode.provenance.push(factorNode.id);
1023
- newNode.provenance.push(denominatorNode.id);
1024
- newNode.provenance.push(node.id);
1025
-
1026
- return newNode;
1027
- },
1028
- (originalNode, ruleData, newNode) => {
1029
- const { pattern, factor } = ruleData;
1030
- if (pattern === 'rational') {
1031
- return `Simplified multiplication and division: (${factor} × expression)/${factor} = expression`;
1032
- } else {
1033
- return `Simplified multiplication and division: ${factor} × (expression/${factor}) = expression`;
1034
- }
1035
- }
1036
- )
1037
- ];
1
+ import { SimplificationEngine } from '../omdSimplificationEngine.js';
2
+ import * as utils from '../simplificationUtils.js';
3
+ import { getMultiplicationSymbol } from '../../config/omdConfigManager.js';
4
+ import { omdRationalNode } from '../../nodes/omdRationalNode.js';
5
+
6
+ // ===== BINARY EXPRESSION RULES =====
7
+ export const binaryRules = [
8
+ // Handle addition cases like a + (-a) = 0
9
+ SimplificationEngine.createRule("Opposite Term Cancellation",
10
+ (node) => {
11
+ // Only handle addition for now
12
+ if (!SimplificationEngine.isBinaryOp(node, 'add')) {
13
+ return false;
14
+ }
15
+
16
+ // Check for constant + (-constant) patterns (2 + (-2) = 0)
17
+ if (node.left.isConstant()) {
18
+ const leftVal = node.left.getValue();
19
+
20
+ // Check if right side is a unary minus of a constant
21
+ if (SimplificationEngine.isType(node.right, 'omdUnaryExpressionNode') &&
22
+ node.right.operation === 'unaryMinus' &&
23
+ node.right.argument.isConstant()) {
24
+ const rightVal = node.right.argument.getValue();
25
+ if (leftVal === rightVal) {
26
+ return {
27
+ leftTerm: node.left,
28
+ rightTerm: node.right,
29
+ termType: 'constant',
30
+ leftValue: leftVal,
31
+ rightValue: -rightVal,
32
+ isNegatedRight: true
33
+ };
34
+ }
35
+ }
36
+
37
+ // Check for direct opposite constants (rare case)
38
+ if (node.right.isConstant()) {
39
+ const rightVal = node.right.getValue();
40
+ if (leftVal === -rightVal) {
41
+ return {
42
+ leftTerm: node.left,
43
+ rightTerm: node.right,
44
+ termType: 'constant',
45
+ leftValue: leftVal,
46
+ rightValue: rightVal,
47
+ isNegatedRight: false
48
+ };
49
+ }
50
+ }
51
+ }
52
+
53
+ // Check if we have a + (-a) pattern for monomials
54
+ const leftMonomial = SimplificationEngine.isMonomial(node.left);
55
+ let rightMonomial = null;
56
+ let isNegatedRight = false;
57
+
58
+ // Check if right side is a unary minus
59
+ if (SimplificationEngine.isType(node.right, 'omdUnaryExpressionNode') &&
60
+ node.right.operation === 'unaryMinus') {
61
+ rightMonomial = SimplificationEngine.isMonomial(node.right.argument);
62
+ isNegatedRight = true;
63
+ } else {
64
+ rightMonomial = SimplificationEngine.isMonomial(node.right);
65
+ }
66
+
67
+ if (leftMonomial && rightMonomial) {
68
+ // For a + (-a), check if left coefficient equals right coefficient
69
+ // For a + (-2a), check if left coefficient equals negative of right coefficient
70
+ let leftCoeff = leftMonomial.coefficient;
71
+ let rightCoeff = rightMonomial.coefficient;
72
+
73
+ if (isNegatedRight) {
74
+ rightCoeff = -rightCoeff; // Since it's wrapped in unary minus
75
+ }
76
+
77
+ // Check if they are the same variable with same power and opposite coefficients
78
+ if (leftMonomial.variable === rightMonomial.variable &&
79
+ leftMonomial.power === rightMonomial.power &&
80
+ leftCoeff === -rightCoeff) {
81
+ return {
82
+ leftTerm: node.left,
83
+ rightTerm: node.right,
84
+ termType: 'monomial',
85
+ variable: leftMonomial.variable,
86
+ power: leftMonomial.power,
87
+ leftCoeff: leftCoeff,
88
+ rightCoeff: rightCoeff,
89
+ isNegatedRight: isNegatedRight
90
+ };
91
+ }
92
+ }
93
+
94
+ return false;
95
+ },
96
+ (node, data) => {
97
+ const { leftTerm, rightTerm } = data;
98
+ const zeroNode = SimplificationEngine.createConstant(0, node.getFontSize());
99
+
100
+ // Preserve provenance from both terms
101
+ zeroNode.provenance.push(leftTerm.id);
102
+ zeroNode.provenance.push(rightTerm.id);
103
+ zeroNode.provenance.push(node.id);
104
+
105
+ return zeroNode;
106
+ },
107
+ (originalNode, ruleData, newNode) => {
108
+ const { termType } = ruleData;
109
+
110
+ if (termType === 'constant') {
111
+ const { leftValue, rightValue, isNegatedRight } = ruleData;
112
+ if (isNegatedRight) {
113
+ return `Cancelled opposite terms: ${leftValue} + (-${leftValue}) = 0`;
114
+ } else {
115
+ return `Cancelled opposite terms: ${leftValue} + ${rightValue} = 0`;
116
+ }
117
+ } else {
118
+ const { variable, power, leftCoeff, rightCoeff, isNegatedRight } = ruleData;
119
+ const powerStr = power !== 1 ? `^${power}` : '';
120
+
121
+ // Format the left term
122
+ const leftCoeffStr = leftCoeff === 1 ? '' : leftCoeff === -1 ? '-' : `${leftCoeff}`;
123
+ const leftTermStr = `${leftCoeffStr}${variable}${powerStr}`;
124
+
125
+ // Format the right term
126
+ let rightTermStr;
127
+ if (isNegatedRight) {
128
+ const innerCoeff = Math.abs(rightCoeff);
129
+ const innerCoeffStr = innerCoeff === 1 ? '' : `${innerCoeff}`;
130
+ rightTermStr = `(-${innerCoeffStr}${variable}${powerStr})`;
131
+ } else {
132
+ const rightCoeffStr = rightCoeff === 1 ? '' : rightCoeff === -1 ? '-' : `${rightCoeff}`;
133
+ rightTermStr = `${rightCoeffStr}${variable}${powerStr}`;
134
+ }
135
+
136
+ return `Cancelled opposite terms: ${leftTermStr} + ${rightTermStr} = 0`;
137
+ }
138
+ }
139
+ ),
140
+
141
+ // Handle constant cancellation in multi-term sums (3x + 2 - 2 → 3x + 0)
142
+ SimplificationEngine.createRule("Cancel Constants in Sums",
143
+ (node) => {
144
+ if (!SimplificationEngine.isBinaryOp(node) ||
145
+ (node.operation !== 'add' && node.operation !== 'subtract')) return false;
146
+
147
+ // Flatten the sum to get all terms
148
+ const terms = [];
149
+ utils.flattenSum(node, terms);
150
+
151
+ // Only proceed if we have at least 3 terms (need non-constants + constants that cancel)
152
+ if (terms.length < 3) return false;
153
+
154
+ const constantTerms = terms.filter(t => t.node.isConstant());
155
+ const nonConstantTerms = terms.filter(t => !t.node.isConstant());
156
+
157
+ // Need at least one non-constant term and at least 2 constants
158
+ if (nonConstantTerms.length === 0 || constantTerms.length < 2) return false;
159
+
160
+ // Check if any constants cancel out exactly
161
+ const cancellingPairs = [];
162
+ const usedIndices = new Set();
163
+
164
+ for (let i = 0; i < constantTerms.length; i++) {
165
+ if (usedIndices.has(i)) continue;
166
+
167
+ const term1 = constantTerms[i];
168
+ const val1 = term1.node.getValue() * term1.sign;
169
+
170
+ for (let j = i + 1; j < constantTerms.length; j++) {
171
+ if (usedIndices.has(j)) continue;
172
+
173
+ const term2 = constantTerms[j];
174
+ const val2 = term2.node.getValue() * term2.sign;
175
+
176
+ // Check if they cancel exactly
177
+ if (val1 + val2 === 0) {
178
+ cancellingPairs.push([term1, term2]);
179
+ usedIndices.add(i);
180
+ usedIndices.add(j);
181
+ break;
182
+ }
183
+ }
184
+ }
185
+
186
+ if (cancellingPairs.length > 0) {
187
+ return {
188
+ terms: terms,
189
+ cancellingPairs: cancellingPairs,
190
+ constantTerms: constantTerms,
191
+ nonConstantTerms: nonConstantTerms
192
+ };
193
+ }
194
+
195
+ return false;
196
+ },
197
+ (node, data) => {
198
+ const { terms, cancellingPairs, constantTerms, nonConstantTerms } = data;
199
+
200
+ // Start with non-constant terms
201
+ const finalTerms = [...nonConstantTerms];
202
+
203
+ // Keep track of which constant terms were cancelled
204
+ const cancelledTerms = new Set();
205
+ cancellingPairs.forEach(pair => {
206
+ pair.forEach(term => cancelledTerms.add(term));
207
+ });
208
+
209
+ // Add back any constant terms that weren't cancelled
210
+ constantTerms.forEach(term => {
211
+ if (!cancelledTerms.has(term)) {
212
+ finalTerms.push(term);
213
+ }
214
+ });
215
+
216
+ // Collect detailed provenance from all cancelled terms
217
+ let cancelledNodeIds = [];
218
+ if (cancellingPairs.length > 0) {
219
+ const zeroNode = SimplificationEngine.createConstant(0, node.getFontSize());
220
+
221
+ // Add provenance from all cancelled terms and their operators
222
+ cancellingPairs.forEach(pair => {
223
+ // Find the operator node between the pair
224
+ const [term1, term2] = pair;
225
+ const parent = terms.find(t =>
226
+ t.node.type === 'omdBinaryExpressionNode' &&
227
+ ((t.node.left === term1.node && t.node.right === term2.node) ||
228
+ (t.node.left === term2.node && t.node.right === term1.node))
229
+ );
230
+
231
+ // Add provenance from the constants and the operator
232
+ pair.forEach(term => {
233
+ zeroNode.provenance.push(term.node.id);
234
+ cancelledNodeIds.push(term.node.id);
235
+
236
+ // If this term has an operator (e.g. the minus sign), include it
237
+ if (term.node.operation) {
238
+ zeroNode.provenance.push(term.node.operation.id);
239
+ cancelledNodeIds.push(term.node.operation.id);
240
+ }
241
+ });
242
+
243
+ // Add provenance from the binary operation's operator
244
+ if (parent && parent.node.operation) {
245
+ zeroNode.provenance.push(parent.node.operation.id);
246
+ cancelledNodeIds.push(parent.node.operation.id);
247
+ }
248
+ });
249
+
250
+ finalTerms.push({ node: zeroNode, sign: 1 });
251
+ }
252
+
253
+ // Build the result tree
254
+ const result = utils.buildSumTree(finalTerms, node.getFontSize());
255
+
256
+ if (result) {
257
+ // Only preserve provenance from the cancelled terms
258
+ cancelledNodeIds.forEach(id => {
259
+ if (!result.provenance.includes(id)) {
260
+ result.provenance.push(id);
261
+ }
262
+ });
263
+ }
264
+
265
+ return result;
266
+ },
267
+ (originalNode, ruleData, newNode) => {
268
+ const { cancellingPairs } = ruleData;
269
+
270
+ const cancellationDescriptions = cancellingPairs.map(pair => {
271
+ const [term1, term2] = pair;
272
+ const val1 = term1.node.getValue();
273
+ const val2 = term2.node.getValue();
274
+ const sign1 = term1.sign === 1 ? '+' : '-';
275
+ const sign2 = term2.sign === 1 ? '+' : '-';
276
+
277
+ return `${sign1} ${val1} ${sign2} ${val2} = 0`;
278
+ });
279
+
280
+ return `Cancelled constants in sum: ${cancellationDescriptions.join(', ')}`;
281
+ }
282
+ ),
283
+
284
+ // Basic constant folding (works for both regular and rational constants)
285
+ SimplificationEngine.createConstantFoldRule("Add Constants", "add"),
286
+ SimplificationEngine.createConstantFoldRule("Subtract Constants", "subtract"),
287
+ SimplificationEngine.createConstantFoldRule("Multiply Constants", "multiply"),
288
+ SimplificationEngine.createConstantFoldRule("Divide Constants", "divide"),
289
+
290
+ // Identity operations
291
+ SimplificationEngine.createIdentityRule("Add Zero", "add", 0), // x + 0 → x, 0 + x → x
292
+ SimplificationEngine.createIdentityRule("Subtract Zero", "subtract", 0, 'right'), // x - 0 → x
293
+ SimplificationEngine.createIdentityRule("Multiply One", "multiply", 1), // x * 1 → x, 1 * x → x
294
+ SimplificationEngine.createIdentityRule("Divide One", "divide", 1, 'right'), // x / 1 → x
295
+
296
+ // Zero multiplication (anything times zero equals zero)
297
+ SimplificationEngine.createZeroMultiplicationRule(),
298
+
299
+ // Coefficient multiplication (2 * 3x → 6x)
300
+ SimplificationEngine.createRule("Combine Coefficients",
301
+ (node) => {
302
+ if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
303
+
304
+ const constOp = SimplificationEngine.hasConstantOperand(node);
305
+ if (!constOp) return false;
306
+
307
+ const otherNode = constOp.other;
308
+ if (!SimplificationEngine.isBinaryOp(otherNode, 'multiply')) return false;
309
+
310
+ const innerConstOp = SimplificationEngine.hasConstantOperand(otherNode);
311
+ if (!innerConstOp) return false;
312
+
313
+ const outerConstant = constOp.constant.getValue();
314
+ const innerConstant = innerConstOp.constant.getValue();
315
+ const expression = innerConstOp.other;
316
+
317
+ return {
318
+ coefficient: outerConstant * innerConstant,
319
+ expression
320
+ };
321
+ },
322
+ (node, data) => {
323
+ const { coefficient, expression } = data;
324
+ const newNode = SimplificationEngine.createMultiplication(
325
+ SimplificationEngine.createConstant(coefficient, node.getFontSize()),
326
+ expression.clone(),
327
+ node.getFontSize()
328
+ );
329
+
330
+ // Preserve provenance from both operands
331
+ newNode.provenance.push(node.id);
332
+ if (newNode.left) {
333
+ newNode.left.provenance.push(node.left.id, node.right.id);
334
+ }
335
+ if (newNode.right) {
336
+ newNode.right.provenance.push(expression.id);
337
+ }
338
+
339
+ return newNode;
340
+ },
341
+ (originalNode, ruleData, newNode) => {
342
+ const { coefficient, expression } = ruleData;
343
+ const constOp = SimplificationEngine.hasConstantOperand(originalNode);
344
+ const innerConstOp = SimplificationEngine.hasConstantOperand(constOp.other);
345
+ const outerVal = constOp.constant.getValue();
346
+ const innerVal = innerConstOp.constant.getValue();
347
+
348
+ return `Combined coefficients: ${outerVal} ${getMultiplicationSymbol()} ${innerVal} = ${coefficient}`;
349
+ }
350
+ ),
351
+
352
+ // Distributive property (2*(x+3) → 2x + 6)
353
+ SimplificationEngine.createRule("Distributive Property",
354
+ (node) => {
355
+ if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
356
+
357
+ const constOp = SimplificationEngine.hasConstantOperand(node);
358
+ if (!constOp) return false;
359
+
360
+ const otherNode = constOp.other;
361
+
362
+ // Check if the other operand is a parenthesized sum/difference
363
+ let innerExpr = otherNode;
364
+ if (SimplificationEngine.isType(otherNode, 'omdParenthesisNode')) {
365
+ innerExpr = otherNode.expression;
366
+ }
367
+
368
+ if (!SimplificationEngine.isBinaryOp(innerExpr, 'add') &&
369
+ !SimplificationEngine.isBinaryOp(innerExpr, 'subtract')) {
370
+ return false;
371
+ }
372
+
373
+ return {
374
+ constantNode: constOp.constant,
375
+ innerExpr: innerExpr,
376
+ originalInnerNode: otherNode
377
+ };
378
+ },
379
+ (node, data) => {
380
+ const { constantNode, innerExpr } = data;
381
+ const multiplier = constantNode.getValue();
382
+ const fontSize = node.getFontSize();
383
+
384
+ // Distribute the constant across the terms
385
+ const terms = [];
386
+ utils.flattenSum(innerExpr, terms);
387
+
388
+ const distributedTerms = terms.map(term => {
389
+ const newCoeff = multiplier * term.sign;
390
+ const distributedNode = SimplificationEngine.createBinaryOp(
391
+ SimplificationEngine.createConstant(Math.abs(newCoeff), fontSize),
392
+ 'multiply',
393
+ term.node.clone(),
394
+ fontSize
395
+ );
396
+
397
+ // Preserve provenance
398
+ distributedNode.provenance.push(node.id, constantNode.id, term.node.id);
399
+
400
+ return {
401
+ node: distributedNode,
402
+ sign: newCoeff >= 0 ? 1 : -1
403
+ };
404
+ });
405
+
406
+ return utils.buildSumTree(distributedTerms, fontSize);
407
+ },
408
+ (originalNode, ruleData, newNode) => {
409
+ const { constantNode, innerExpr } = ruleData;
410
+ const multiplierStr = constantNode.toString();
411
+ const expressionStr = innerExpr.toString();
412
+
413
+ return `Applied distributive property: ${multiplierStr} ${getMultiplicationSymbol()} (${expressionStr})`;
414
+ }
415
+ ),
416
+
417
+ // Expand polynomial multiplication like (3x+3)(2x+2) using FOIL/distributive property
418
+ SimplificationEngine.createRule("Expand Polynomial Multiplication",
419
+ (node) => {
420
+ if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
421
+
422
+ // Both operands should be sums or differences (optionally parenthesized)
423
+ let leftExpr = SimplificationEngine.unwrapParentheses(node.left);
424
+ let rightExpr = SimplificationEngine.unwrapParentheses(node.right);
425
+
426
+ // Check if both sides are sums or differences
427
+ const leftIsSum = SimplificationEngine.isBinaryOp(leftExpr, 'add') ||
428
+ SimplificationEngine.isBinaryOp(leftExpr, 'subtract');
429
+ const rightIsSum = SimplificationEngine.isBinaryOp(rightExpr, 'add') ||
430
+ SimplificationEngine.isBinaryOp(rightExpr, 'subtract');
431
+
432
+ if (!leftIsSum || !rightIsSum) return false;
433
+
434
+ // Extract terms from both expressions
435
+ const leftTerms = [];
436
+ const rightTerms = [];
437
+ utils.flattenSum(leftExpr, leftTerms);
438
+ utils.flattenSum(rightExpr, rightTerms);
439
+
440
+ // Limit to reasonable number of terms to avoid explosion
441
+ if (leftTerms.length > 4 || rightTerms.length > 4) return false;
442
+
443
+ return {
444
+ leftExpression: leftExpr,
445
+ rightExpression: rightExpr,
446
+ leftTerms: leftTerms,
447
+ rightTerms: rightTerms
448
+ };
449
+ },
450
+ (node, data) => {
451
+ const { leftTerms, rightTerms } = data;
452
+ const fontSize = node.getFontSize();
453
+
454
+ // Apply distributive property: each term in left multiplied by each term in right
455
+ const expandedTerms = [];
456
+
457
+ for (const leftTerm of leftTerms) {
458
+ for (const rightTerm of rightTerms) {
459
+ const productSign = leftTerm.sign * rightTerm.sign;
460
+ const productNode = SimplificationEngine.createBinaryOp(
461
+ leftTerm.node.clone(),
462
+ 'multiply',
463
+ rightTerm.node.clone(),
464
+ fontSize
465
+ );
466
+
467
+ // Use granular provenance
468
+ const allLeafNodes = [...(leftTerm.leafNodes || []), ...(rightTerm.leafNodes || [])];
469
+ allLeafNodes.forEach(leafNode => {
470
+ [productNode.left, productNode.right, productNode].forEach(part => {
471
+ if (part && !part.provenance.includes(leafNode.id)) {
472
+ part.provenance.push(leafNode.id);
473
+ }
474
+ });
475
+ });
476
+
477
+ expandedTerms.push({
478
+ node: productNode,
479
+ sign: productSign,
480
+ leafNodes: allLeafNodes
481
+ });
482
+ }
483
+ }
484
+
485
+ return utils.buildSumTree(expandedTerms, fontSize);
486
+ },
487
+ (originalNode, ruleData, newNode) => {
488
+ const { leftExpression, rightExpression } = ruleData;
489
+ const leftStr = utils.nodeToString(leftExpression);
490
+ const rightStr = utils.nodeToString(rightExpression);
491
+ return `Expanded polynomial multiplication: (${leftStr})(${rightStr})`;
492
+ }
493
+ ),
494
+
495
+ // Multiply monomials (2x * 3x -> 6x^2, 3y^2 * 4y -> 12y^3)
496
+ SimplificationEngine.createRule("Multiply Monomials",
497
+ (node) => {
498
+ if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
499
+
500
+ // Check if both operands are monomials
501
+ const leftMonomial = SimplificationEngine.isMonomial(node.left);
502
+ const rightMonomial = SimplificationEngine.isMonomial(node.right);
503
+
504
+ if (!leftMonomial || !rightMonomial) return false;
505
+
506
+ // Check if they have the same variable
507
+ if (leftMonomial.variable !== rightMonomial.variable) return false;
508
+
509
+ return {
510
+ variable: leftMonomial.variable,
511
+ leftCoeff: leftMonomial.coefficient,
512
+ rightCoeff: rightMonomial.coefficient,
513
+ leftPower: leftMonomial.power,
514
+ rightPower: rightMonomial.power,
515
+ leftNode: node.left,
516
+ rightNode: node.right
517
+ };
518
+ },
519
+ (node, data) => {
520
+ const { variable, leftCoeff, rightCoeff, leftPower, rightPower, leftNode, rightNode } = data;
521
+ const fontSize = node.getFontSize();
522
+
523
+ // Calculate new coefficient and power
524
+ const newCoeff = leftCoeff * rightCoeff;
525
+ const newPower = leftPower + rightPower;
526
+
527
+ // Extract granular provenance
528
+ const leftProvenance = utils.extractMonomialProvenance(leftNode);
529
+ const rightProvenance = utils.extractMonomialProvenance(rightNode);
530
+
531
+ const coefficientProvenance = [
532
+ ...leftProvenance.coefficientNodes.map(n => n.id),
533
+ ...rightProvenance.coefficientNodes.map(n => n.id)
534
+ ];
535
+
536
+ const variableProvenance = [
537
+ ...leftProvenance.variableNodes.map(n => n.id),
538
+ ...rightProvenance.variableNodes.map(n => n.id)
539
+ ];
540
+
541
+ // Create the result with granular provenance
542
+ const result = utils.createMonomialWithGranularProvenance(
543
+ newCoeff,
544
+ variable,
545
+ newPower,
546
+ fontSize,
547
+ coefficientProvenance,
548
+ variableProvenance
549
+ );
550
+
551
+ result.provenance.push(node.id);
552
+
553
+ return result;
554
+ },
555
+ (originalNode, ruleData, newNode) => {
556
+ const { leftCoeff, rightCoeff, variable, leftPower, rightPower } = ruleData;
557
+ const newCoeff = leftCoeff * rightCoeff;
558
+ const newPower = leftPower + rightPower;
559
+ const leftStr = utils.nodeToString(ruleData.leftNode);
560
+ const rightStr = utils.nodeToString(ruleData.rightNode);
561
+
562
+ return `Multiplied monomials: ${leftStr} ${getMultiplicationSymbol()} ${rightStr} = ${newCoeff}${variable}${newPower > 1 ? `^${newPower}` : ''}`;
563
+ }
564
+ ),
565
+
566
+ // Combine identical terms in multiplication to create powers (x*x -> x^2, y*y*y -> y^3)
567
+ SimplificationEngine.createRule("Combine Like Factors",
568
+ (node) => {
569
+ if (!SimplificationEngine.isBinaryOp(node, 'multiply')) return false;
570
+
571
+ // Check if both operands are identical variables or simple expressions
572
+ if (SimplificationEngine.isType(node.left, 'omdVariableNode') &&
573
+ SimplificationEngine.isType(node.right, 'omdVariableNode') &&
574
+ node.left.name === node.right.name) {
575
+ return {
576
+ variable: node.left.name,
577
+ leftNode: node.left,
578
+ rightNode: node.right,
579
+ power: 2
580
+ };
581
+ }
582
+
583
+ // Check for more complex patterns like x^2 * x -> x^3
584
+ let baseVar = null;
585
+ let leftPower = 1;
586
+ let rightPower = 1;
587
+ let leftNode = node.left;
588
+ let rightNode = node.right;
589
+
590
+ // Analyze left operand
591
+ if (SimplificationEngine.isType(node.left, 'omdVariableNode')) {
592
+ baseVar = node.left.name;
593
+ leftPower = 1;
594
+ } else if (SimplificationEngine.isType(node.left, 'omdPowerNode') &&
595
+ SimplificationEngine.isType(node.left.base, 'omdVariableNode') &&
596
+ node.left.exponent.isConstant()) {
597
+ baseVar = node.left.base.name;
598
+ leftPower = node.left.exponent.getValue();
599
+ }
600
+
601
+ // Analyze right operand
602
+ if (SimplificationEngine.isType(node.right, 'omdVariableNode')) {
603
+ if (baseVar === node.right.name) {
604
+ rightPower = 1;
605
+ } else {
606
+ return false;
607
+ }
608
+ } else if (SimplificationEngine.isType(node.right, 'omdPowerNode') &&
609
+ SimplificationEngine.isType(node.right.base, 'omdVariableNode') &&
610
+ node.right.exponent.isConstant()) {
611
+ if (baseVar === node.right.base.name) {
612
+ rightPower = node.right.exponent.getValue();
613
+ } else {
614
+ return false;
615
+ }
616
+ } else {
617
+ return false;
618
+ }
619
+
620
+ if (baseVar && Number.isInteger(leftPower) && Number.isInteger(rightPower)) {
621
+ return {
622
+ variable: baseVar,
623
+ leftNode: leftNode,
624
+ rightNode: rightNode,
625
+ power: leftPower + rightPower
626
+ };
627
+ }
628
+
629
+ return false;
630
+ },
631
+ (node, data) => {
632
+ const { variable, power, leftNode, rightNode } = data;
633
+ const fontSize = node.getFontSize();
634
+
635
+ let result;
636
+ if (power === 1) {
637
+ result = SimplificationEngine.createMonomial(1, variable, 1, fontSize);
638
+ } else {
639
+ const variableNode = SimplificationEngine.createMonomial(1, variable, 1, fontSize);
640
+ result = utils.createPowerTerm(variableNode, power, fontSize);
641
+ }
642
+
643
+ // Preserve provenance from both original factors
644
+ result.provenance.push(leftNode.id, rightNode.id, node.id);
645
+
646
+ return result;
647
+ },
648
+ (originalNode, ruleData, newNode) => {
649
+ const { variable, power } = ruleData;
650
+ const leftStr = utils.nodeToString(ruleData.leftNode);
651
+ const rightStr = utils.nodeToString(ruleData.rightNode);
652
+
653
+ return `Combined like factors: ${leftStr} ${getMultiplicationSymbol()} ${rightStr} = ${variable}${power > 1 ? `^${power}` : ''}`;
654
+ }
655
+ ),
656
+
657
+ // Complex sum folding (x + 2 + 3 → x + 5)
658
+ SimplificationEngine.createRule("Combine Multiple Constants in Sums",
659
+ (node) => {
660
+ if (!SimplificationEngine.isBinaryOp(node) ||
661
+ (node.operation !== 'add' && node.operation !== 'subtract')) return false;
662
+
663
+ // Flatten the sum
664
+ const terms = [];
665
+ utils.flattenSum(node, terms);
666
+
667
+ const constantCount = terms.filter(t => t.node.isConstant()).length;
668
+ if (constantCount <= 1) return false;
669
+
670
+ // Calculate what the combined constant would be
671
+ const constantTerms = terms.filter(t => t.node.isConstant());
672
+ let totalNum = 0, totalDen = 1;
673
+ for (const term of constantTerms) {
674
+ const { num, den } = term.node.getRationalValue();
675
+ const newTotalNum = (totalNum * den) + (num * term.sign * totalDen);
676
+ const newTotalDen = totalDen * den;
677
+ totalNum = newTotalNum;
678
+ totalDen = newTotalDen;
679
+ const commonDivisor = utils.gcd(Math.abs(totalNum), Math.abs(totalDen));
680
+ totalNum /= commonDivisor;
681
+ totalDen /= commonDivisor;
682
+ }
683
+
684
+ // Don't handle cases where the result would be zero - let cancellation rules handle those
685
+ if (totalNum === 0) return false;
686
+
687
+ return { terms };
688
+ },
689
+ (node, data) => {
690
+ const { terms } = data;
691
+
692
+ const constantTerms = terms.filter(t => t.node.isConstant());
693
+ const otherTerms = terms.filter(t => !t.node.isConstant());
694
+
695
+ // Get the actual constant nodes for provenance
696
+ const constantNodes = constantTerms.map(t => t.node);
697
+
698
+ // Combine all constants using rational arithmetic
699
+ let totalNum = 0, totalDen = 1;
700
+ for (const term of constantTerms) {
701
+ const { num, den } = term.node.getRationalValue();
702
+ const newTotalNum = (totalNum * den) + (num * term.sign * totalDen);
703
+ const newTotalDen = totalDen * den;
704
+ totalNum = newTotalNum;
705
+ totalDen = newTotalDen;
706
+ const commonDivisor = utils.gcd(Math.abs(totalNum), Math.abs(totalDen));
707
+ totalNum /= commonDivisor;
708
+ totalDen /= commonDivisor;
709
+ }
710
+
711
+ // Add combined constant back to terms if non-zero
712
+ let finalTerms = [...otherTerms];
713
+
714
+ if (totalNum !== 0 || finalTerms.length === 0) {
715
+ if (totalDen < 0) { totalNum = -totalNum; totalDen = -totalDen; }
716
+ const sign = totalNum >= 0 ? 1 : -1;
717
+ const absNum = Math.abs(totalNum);
718
+
719
+ let newNode;
720
+ if (totalDen === 1) {
721
+ // Create a simple constant with automatic provenance tracking
722
+ newNode = SimplificationEngine.createConstant(absNum, node.getFontSize(), ...constantNodes);
723
+ } else {
724
+ // Create a rational node
725
+ newNode = new omdRationalNode({
726
+ type: 'OperatorNode',
727
+ fn: 'divide',
728
+ args: [
729
+ { type: 'ConstantNode', value: absNum },
730
+ { type: 'ConstantNode', value: totalDen }
731
+ ]
732
+ });
733
+ newNode.setFontSize(node.getFontSize());
734
+ // Apply provenance for rational nodes manually using the same approach
735
+ constantNodes.forEach(sourceNode => {
736
+ if (sourceNode.id && !newNode.provenance.includes(sourceNode.id)) {
737
+ newNode.provenance.push(sourceNode.id);
738
+ }
739
+ });
740
+ }
741
+
742
+ finalTerms.push({ node: newNode, sign: sign });
743
+ }
744
+
745
+ if (finalTerms.length === 0) {
746
+ const zeroNode = SimplificationEngine.createConstant(0, node.getFontSize(), ...constantNodes);
747
+ return zeroNode;
748
+ }
749
+
750
+ const result = utils.buildSumTree(finalTerms, node.getFontSize());
751
+
752
+ if(result) {
753
+ constantNodes.forEach(sourceNode => {
754
+ if (sourceNode.id && !result.provenance.includes(sourceNode.id)) {
755
+ result.provenance.push(sourceNode.id);
756
+ }
757
+ });
758
+ result.provenance.push(node.id);
759
+ }
760
+
761
+ return result;
762
+ },
763
+ (originalNode, ruleData, newNode) => {
764
+ const { terms } = ruleData;
765
+ const constantTerms = terms.filter(t => t.node.isConstant());
766
+
767
+ // Calculate the combined value
768
+ let totalNum = 0, totalDen = 1;
769
+ for (const term of constantTerms) {
770
+ const { num, den } = term.node.getRationalValue();
771
+ const newTotalNum = (totalNum * den) + (num * term.sign * totalDen);
772
+ const newTotalDen = totalDen * den;
773
+ totalNum = newTotalNum;
774
+ totalDen = newTotalDen;
775
+ const commonDivisor = utils.gcd(Math.abs(totalNum), Math.abs(totalDen));
776
+ totalNum /= commonDivisor;
777
+ totalDen /= commonDivisor;
778
+ }
779
+
780
+ const constantStrings = constantTerms.map(t => {
781
+ const valueStr = utils.nodeToString(t.node);
782
+ return t.sign === 1 ? `+ ${valueStr}` : `- ${valueStr}`;
783
+ });
784
+ // For the first term, if it's positive, remove the leading "+ ".
785
+ if (constantStrings.length > 0 && constantStrings[0].startsWith('+ ')) {
786
+ constantStrings[0] = constantStrings[0].substring(2);
787
+ }
788
+
789
+ const calculation = constantStrings.join(' ');
790
+ const resultStr = totalDen === 1 ? `${totalNum}` : `${totalNum}/${totalDen}`;
791
+
792
+ let message = `Combining the constant terms: ${calculation} = ${resultStr}. `;
793
+
794
+ if (totalNum === 0) {
795
+ message += `Since the constants sum to zero, they are replaced by 0.`;
796
+ } else {
797
+ message += `The constants are replaced by their sum.`;
798
+ }
799
+
800
+ return message;
801
+ }
802
+ ),
803
+
804
+ // Combine like terms (monomials with same variable and power)
805
+ SimplificationEngine.createRule("Combine Like Terms",
806
+ (node) => {
807
+ if (!SimplificationEngine.isBinaryOp(node, 'add') && !SimplificationEngine.isBinaryOp(node, 'subtract')) {
808
+ return false;
809
+ }
810
+
811
+ const terms = [];
812
+ utils.flattenSum(node, terms);
813
+
814
+ const likeTermGroups = new Map();
815
+ const otherTerms = [];
816
+
817
+ for (const term of terms) {
818
+ const monomialInfo = SimplificationEngine.isMonomial(term.node);
819
+
820
+ if (monomialInfo) {
821
+ const key = `${monomialInfo.variable}^${monomialInfo.power}`;
822
+ if (!likeTermGroups.has(key)) {
823
+ likeTermGroups.set(key, []);
824
+ }
825
+
826
+ likeTermGroups.get(key).push({
827
+ term,
828
+ monomialInfo,
829
+ originalNodeId: term.node.id
830
+ });
831
+ } else {
832
+ otherTerms.push(term);
833
+ }
834
+ }
835
+
836
+ // Check if we have like terms to combine
837
+ const foundLikeTerms = Array.from(likeTermGroups.values()).some(group => group.length > 1);
838
+
839
+ return foundLikeTerms ? { likeTermGroups, otherTerms } : false;
840
+ },
841
+ (node, data) => {
842
+ const { likeTermGroups, otherTerms } = data;
843
+ // Start with a copy of the original terms array (to preserve order)
844
+ let allNewTerms = [...otherTerms];
845
+ // We'll build a map from node id to its index in the original terms array
846
+ const originalTerms = [];
847
+ utils.flattenSum(node, originalTerms);
848
+
849
+ // For each group of like terms
850
+ for (const [key, termGroup] of likeTermGroups) {
851
+ if (termGroup.length === 1) {
852
+ // Find the index of this term in the original terms array
853
+ const idx = originalTerms.findIndex(t => t.node.id === termGroup[0].term.node.id);
854
+ if (idx !== -1) {
855
+ allNewTerms.splice(idx, 0, termGroup[0].term);
856
+ } else {
857
+ allNewTerms.push(termGroup[0].term);
858
+ }
859
+ } else {
860
+ // Combine multiple like terms
861
+ let totalCoeff = 0;
862
+ const coefficientProvenance = [];
863
+ const variableProvenance = [];
864
+ const likeTermIds = termGroup.map(t => t.term.node.id);
865
+ for (const termData of termGroup) {
866
+ const coeff = termData.monomialInfo.coefficient * termData.term.sign;
867
+ totalCoeff += coeff;
868
+ // Extract granular provenance
869
+ const monomialProvenance = utils.extractMonomialProvenance(termData.term.node);
870
+ monomialProvenance.coefficientNodes.forEach(coeffNode => {
871
+ if (!coefficientProvenance.includes(coeffNode.id)) {
872
+ coefficientProvenance.push(coeffNode.id);
873
+ }
874
+ });
875
+ monomialProvenance.variableNodes.forEach(varNode => {
876
+ if (!variableProvenance.includes(varNode.id)) {
877
+ variableProvenance.push(varNode.id);
878
+ }
879
+ });
880
+ }
881
+ // Create combined term if coefficient is non-zero
882
+ if (totalCoeff !== 0) {
883
+ const firstTerm = termGroup[0];
884
+ const newMonomial = utils.createMonomialWithGranularProvenance(
885
+ totalCoeff,
886
+ firstTerm.monomialInfo.variable,
887
+ firstTerm.monomialInfo.power,
888
+ node.getFontSize(),
889
+ coefficientProvenance,
890
+ variableProvenance
891
+ );
892
+ // Find the leftmost index of any like term in the original terms array
893
+ const leftmostIdx = originalTerms.findIndex(t => likeTermIds.includes(t.node.id));
894
+ // Remove all like terms from allNewTerms (by node id)
895
+ allNewTerms = allNewTerms.filter(t => !likeTermIds.includes(t.node.id));
896
+ // Insert the new combined term at the leftmost index
897
+ if (leftmostIdx !== -1) {
898
+ allNewTerms.splice(leftmostIdx, 0, { node: newMonomial, sign: 1 });
899
+ } else {
900
+ allNewTerms.push({ node: newMonomial, sign: 1 });
901
+ }
902
+ } else {
903
+ // If the sum is zero, just remove all like terms
904
+ allNewTerms = allNewTerms.filter(t => !likeTermIds.includes(t.node.id));
905
+ }
906
+ }
907
+ }
908
+
909
+ // Handle zero case
910
+ if (allNewTerms.length === 0) {
911
+ return SimplificationEngine.createConstant(0, node.getFontSize());
912
+ }
913
+
914
+ return utils.buildSumTree(allNewTerms, node.getFontSize());
915
+ },
916
+ (originalNode, ruleData, newNode) => {
917
+ const { likeTermGroups } = ruleData;
918
+ const combinations = [];
919
+
920
+ for (const [key, termGroup] of likeTermGroups) {
921
+ if (termGroup.length > 1) {
922
+ let totalCoeff = 0;
923
+ const termDetails = [];
924
+
925
+ for (const termData of termGroup) {
926
+ const coeff = termData.monomialInfo.coefficient * termData.term.sign;
927
+ totalCoeff += coeff;
928
+
929
+ // Format individual terms for the explanation
930
+ const variable = termData.monomialInfo.variable;
931
+ const power = termData.monomialInfo.power;
932
+ const powerStr = power !== 1 ? `^${power}` : '';
933
+
934
+ if (coeff === 1) {
935
+ termDetails.push(`${variable}${powerStr}`);
936
+ } else if (coeff === -1) {
937
+ termDetails.push(`-${variable}${powerStr}`);
938
+ } else {
939
+ termDetails.push(`${coeff}${variable}${powerStr}`);
940
+ }
941
+ }
942
+
943
+ const variable = termGroup[0].monomialInfo.variable;
944
+ const power = termGroup[0].monomialInfo.power;
945
+ const powerStr = power !== 1 ? `^${power}` : '';
946
+
947
+ if (totalCoeff === 0) {
948
+ combinations.push(`${termDetails.join(' + ').replace('+ -', '- ')} = 0 (like terms cancelled)`);
949
+ } else {
950
+ const resultStr = totalCoeff === 1 ? `${variable}${powerStr}` :
951
+ totalCoeff === -1 ? `-${variable}${powerStr}` :
952
+ `${totalCoeff}${variable}${powerStr}`;
953
+ combinations.push(`${termDetails.join(' + ').replace('+ -', '- ')} = ${resultStr}`);
954
+ }
955
+ }
956
+ }
957
+
958
+ if (combinations.length === 0) {
959
+ return "No like terms were found to combine";
960
+ } else if (combinations.length === 1) {
961
+ return `Combined like terms: ${combinations[0]}`;
962
+ } else {
963
+ return `Combined like terms: ${combinations.join('; ')}`;
964
+ }
965
+ }
966
+ ),
967
+
968
+ // Multiply then divide by same factor: (a*x)/a → x or a*(x/a) → x
969
+ SimplificationEngine.createRule("Multiply Divide Same Factor",
970
+ (node) => {
971
+ // Check for (a*x)/a pattern (rational node with multiplication in numerator)
972
+ if (SimplificationEngine.isType(node, 'omdRationalNode')) {
973
+ const numerator = SimplificationEngine.unwrapParentheses(node.numerator);
974
+ const denominator = SimplificationEngine.unwrapParentheses(node.denominator);
975
+
976
+ if (SimplificationEngine.isBinaryOp(numerator, 'multiply') &&
977
+ SimplificationEngine.isType(denominator, 'omdConstantNode')) {
978
+
979
+ const constOp = SimplificationEngine.hasConstantOperand(numerator);
980
+ if (constOp && constOp.constant.getValue() === denominator.getValue()) {
981
+ return {
982
+ pattern: 'rational',
983
+ factor: constOp.constant.getValue(),
984
+ expression: constOp.other,
985
+ factorNode: constOp.constant,
986
+ denominatorNode: denominator
987
+ };
988
+ }
989
+ }
990
+ }
991
+
992
+ // Check for a*(x/a) pattern (multiplication with rational)
993
+ if (SimplificationEngine.isBinaryOp(node, 'multiply')) {
994
+ const constOp = SimplificationEngine.hasConstantOperand(node);
995
+ if (!constOp) return false;
996
+
997
+ const otherNode = constOp.other;
998
+ if (SimplificationEngine.isType(otherNode, 'omdRationalNode')) {
999
+ const denominator = SimplificationEngine.unwrapParentheses(otherNode.denominator);
1000
+
1001
+ if (SimplificationEngine.isType(denominator, 'omdConstantNode') &&
1002
+ constOp.constant.getValue() === denominator.getValue()) {
1003
+
1004
+ return {
1005
+ pattern: 'multiply',
1006
+ factor: constOp.constant.getValue(),
1007
+ expression: SimplificationEngine.unwrapParentheses(otherNode.numerator),
1008
+ factorNode: constOp.constant,
1009
+ denominatorNode: denominator
1010
+ };
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ return false;
1016
+ },
1017
+ (node, data) => {
1018
+ const { expression, factorNode, denominatorNode } = data;
1019
+ const newNode = expression.clone();
1020
+
1021
+ // Preserve provenance
1022
+ newNode.provenance.push(factorNode.id);
1023
+ newNode.provenance.push(denominatorNode.id);
1024
+ newNode.provenance.push(node.id);
1025
+
1026
+ return newNode;
1027
+ },
1028
+ (originalNode, ruleData, newNode) => {
1029
+ const { pattern, factor } = ruleData;
1030
+ if (pattern === 'rational') {
1031
+ return `Simplified multiplication and division: (${factor} × expression)/${factor} = expression`;
1032
+ } else {
1033
+ return `Simplified multiplication and division: ${factor} × (expression/${factor}) = expression`;
1034
+ }
1035
+ }
1036
+ )
1037
+ ];