verifiable-thinking-mcp 0.4.0
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.
- package/LICENSE +21 -0
- package/README.md +339 -0
- package/package.json +75 -0
- package/src/index.ts +38 -0
- package/src/lib/cache.ts +246 -0
- package/src/lib/compression.ts +804 -0
- package/src/lib/compute/cache.ts +86 -0
- package/src/lib/compute/classifier.ts +555 -0
- package/src/lib/compute/confidence.ts +79 -0
- package/src/lib/compute/context.ts +154 -0
- package/src/lib/compute/extract.ts +200 -0
- package/src/lib/compute/filter.ts +224 -0
- package/src/lib/compute/index.ts +171 -0
- package/src/lib/compute/math.ts +247 -0
- package/src/lib/compute/patterns.ts +564 -0
- package/src/lib/compute/registry.ts +145 -0
- package/src/lib/compute/solvers/arithmetic.ts +65 -0
- package/src/lib/compute/solvers/calculus.ts +249 -0
- package/src/lib/compute/solvers/derivation-core.ts +371 -0
- package/src/lib/compute/solvers/derivation-latex.ts +160 -0
- package/src/lib/compute/solvers/derivation-mistakes.ts +1046 -0
- package/src/lib/compute/solvers/derivation-simplify.ts +451 -0
- package/src/lib/compute/solvers/derivation-transform.ts +620 -0
- package/src/lib/compute/solvers/derivation.ts +67 -0
- package/src/lib/compute/solvers/facts.ts +120 -0
- package/src/lib/compute/solvers/formula.ts +728 -0
- package/src/lib/compute/solvers/index.ts +36 -0
- package/src/lib/compute/solvers/logic.ts +422 -0
- package/src/lib/compute/solvers/probability.ts +307 -0
- package/src/lib/compute/solvers/statistics.ts +262 -0
- package/src/lib/compute/solvers/word-problems.ts +408 -0
- package/src/lib/compute/types.ts +107 -0
- package/src/lib/concepts.ts +111 -0
- package/src/lib/domain.ts +731 -0
- package/src/lib/extraction.ts +912 -0
- package/src/lib/index.ts +122 -0
- package/src/lib/judge.ts +260 -0
- package/src/lib/math/ast.ts +842 -0
- package/src/lib/math/index.ts +8 -0
- package/src/lib/math/operators.ts +171 -0
- package/src/lib/math/tokenizer.ts +477 -0
- package/src/lib/patterns.ts +200 -0
- package/src/lib/session.ts +825 -0
- package/src/lib/think/challenge.ts +323 -0
- package/src/lib/think/complexity.ts +504 -0
- package/src/lib/think/confidence-drift.ts +507 -0
- package/src/lib/think/consistency.ts +347 -0
- package/src/lib/think/guidance.ts +188 -0
- package/src/lib/think/helpers.ts +568 -0
- package/src/lib/think/hypothesis.ts +216 -0
- package/src/lib/think/index.ts +127 -0
- package/src/lib/think/prompts.ts +262 -0
- package/src/lib/think/route.ts +358 -0
- package/src/lib/think/schema.ts +98 -0
- package/src/lib/think/scratchpad-schema.ts +662 -0
- package/src/lib/think/spot-check.ts +961 -0
- package/src/lib/think/types.ts +93 -0
- package/src/lib/think/verification.ts +260 -0
- package/src/lib/tokens.ts +177 -0
- package/src/lib/verification.ts +620 -0
- package/src/prompts/index.ts +10 -0
- package/src/prompts/templates.ts +336 -0
- package/src/resources/index.ts +8 -0
- package/src/resources/sessions.ts +196 -0
- package/src/tools/compress.ts +138 -0
- package/src/tools/index.ts +5 -0
- package/src/tools/scratchpad.ts +2659 -0
- package/src/tools/sessions.ts +144 -0
|
@@ -0,0 +1,1046 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derivation Mistake Detection - detects common algebraic errors
|
|
3
|
+
*
|
|
4
|
+
* Provides pattern-based detection of common student mistakes in
|
|
5
|
+
* algebraic derivations, with explanations and fix suggestions.
|
|
6
|
+
*
|
|
7
|
+
* @module derivation-mistakes
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ASTNode, BinaryNode } from "../../math/ast.ts";
|
|
11
|
+
import { compareExpressions, formatAST } from "../../verification.ts";
|
|
12
|
+
import { extractDerivationSteps } from "./derivation-core.ts";
|
|
13
|
+
import { parseToAST } from "./derivation-simplify.ts";
|
|
14
|
+
import { gcd, nodesEqual, normalizeOperator } from "./derivation-transform.ts";
|
|
15
|
+
|
|
16
|
+
/** Types of common algebraic mistakes */
|
|
17
|
+
export type MistakeType =
|
|
18
|
+
| "sign_error"
|
|
19
|
+
| "distribution_error"
|
|
20
|
+
| "subtraction_distribution_error"
|
|
21
|
+
| "cancellation_error"
|
|
22
|
+
| "coefficient_error"
|
|
23
|
+
| "exponent_error"
|
|
24
|
+
| "order_of_operations"
|
|
25
|
+
| "fraction_error"
|
|
26
|
+
| "like_terms_error"
|
|
27
|
+
| "power_rule_error"
|
|
28
|
+
| "chain_rule_error"
|
|
29
|
+
| "product_rule_error";
|
|
30
|
+
|
|
31
|
+
/** A detected common mistake */
|
|
32
|
+
export interface DetectedMistake {
|
|
33
|
+
/** Type of mistake */
|
|
34
|
+
type: MistakeType;
|
|
35
|
+
/** Step number where mistake occurred (1-indexed) */
|
|
36
|
+
stepNumber: number;
|
|
37
|
+
/** Confidence that this is the actual mistake (0-1) */
|
|
38
|
+
confidence: number;
|
|
39
|
+
/** What the student wrote */
|
|
40
|
+
found: string;
|
|
41
|
+
/** What was likely intended or correct */
|
|
42
|
+
expected?: string;
|
|
43
|
+
/** Human-readable explanation */
|
|
44
|
+
explanation: string;
|
|
45
|
+
/** Specific fix suggestion */
|
|
46
|
+
suggestion: string;
|
|
47
|
+
/** The corrected derivation step (e.g., "2x + 3x = 5x") */
|
|
48
|
+
suggestedFix?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Result of mistake detection */
|
|
52
|
+
export interface MistakeDetectionResult {
|
|
53
|
+
/** Whether any mistakes were detected */
|
|
54
|
+
hasMistakes: boolean;
|
|
55
|
+
/** List of detected mistakes */
|
|
56
|
+
mistakes: DetectedMistake[];
|
|
57
|
+
/** Overall assessment */
|
|
58
|
+
summary: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Escape special regex characters */
|
|
62
|
+
function escapeRegex(str: string): string {
|
|
63
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check for chain rule error: d/dx f(g(x)) missing the inner derivative
|
|
68
|
+
*/
|
|
69
|
+
function checkChainRuleError(
|
|
70
|
+
lhs: string,
|
|
71
|
+
rhs: string,
|
|
72
|
+
_lhsAst: ASTNode | null,
|
|
73
|
+
_rhsAst: ASTNode | null,
|
|
74
|
+
): DetectedMistake | null {
|
|
75
|
+
// Match derivative of composite function patterns
|
|
76
|
+
const derivPatterns = [
|
|
77
|
+
{
|
|
78
|
+
pattern: /(?:d\/dx|derivative\s+of)\s*sin\s*\(\s*([^)]+)\s*\)/i,
|
|
79
|
+
outer: "sin",
|
|
80
|
+
outerDeriv: "cos",
|
|
81
|
+
getInner: (m: RegExpMatchArray) => m[1]!.trim(),
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
pattern: /(?:d\/dx|derivative\s+of)\s*cos\s*\(\s*([^)]+)\s*\)/i,
|
|
85
|
+
outer: "cos",
|
|
86
|
+
outerDeriv: "-sin",
|
|
87
|
+
getInner: (m: RegExpMatchArray) => m[1]!.trim(),
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
pattern: /(?:d\/dx|derivative\s+of)\s*(?:e\^|exp)\s*\(\s*([^)]+)\s*\)/i,
|
|
91
|
+
outer: "e^",
|
|
92
|
+
outerDeriv: "e^",
|
|
93
|
+
getInner: (m: RegExpMatchArray) => m[1]!.trim(),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
pattern: /(?:d\/dx|derivative\s+of)\s*ln\s*\(\s*([^)]+)\s*\)/i,
|
|
97
|
+
outer: "ln",
|
|
98
|
+
outerDeriv: "1/",
|
|
99
|
+
getInner: (m: RegExpMatchArray) => m[1]!.trim(),
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
pattern: /(?:d\/dx|derivative\s+of)\s*\(\s*([^)]+)\s*\)\s*\^\s*(\d+)/i,
|
|
103
|
+
outer: "power",
|
|
104
|
+
outerDeriv: null,
|
|
105
|
+
getInner: (m: RegExpMatchArray) => m[1]!.trim(),
|
|
106
|
+
getExp: (m: RegExpMatchArray) => parseInt(m[2]!, 10),
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const { pattern, outer, outerDeriv: _outerDeriv, getInner, getExp } of derivPatterns) {
|
|
111
|
+
const match = lhs.match(pattern);
|
|
112
|
+
if (!match) continue;
|
|
113
|
+
|
|
114
|
+
const inner = getInner(match);
|
|
115
|
+
|
|
116
|
+
// Skip if inner is just a single variable (not composite)
|
|
117
|
+
if (/^[a-zA-Z]$/.test(inner)) continue;
|
|
118
|
+
|
|
119
|
+
// Check if the inner function needs chain rule
|
|
120
|
+
const hasComposite = /[+\-*/^]|\d[a-zA-Z]|[a-zA-Z]\d/.test(inner);
|
|
121
|
+
if (!hasComposite) continue;
|
|
122
|
+
|
|
123
|
+
// Compute the inner derivative (simplified heuristics)
|
|
124
|
+
let innerDeriv: string | null = null;
|
|
125
|
+
|
|
126
|
+
// x^n -> nx^(n-1)
|
|
127
|
+
const powerMatch = inner.match(/^([a-zA-Z])\s*\^\s*(\d+)$/);
|
|
128
|
+
if (powerMatch) {
|
|
129
|
+
const v = powerMatch[1]!;
|
|
130
|
+
const n = parseInt(powerMatch[2]!, 10);
|
|
131
|
+
innerDeriv = n === 2 ? `2${v}` : `${n}${v}^${n - 1}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ax -> a (linear)
|
|
135
|
+
const linearMatch = inner.match(/^(\d+)\s*([a-zA-Z])$/);
|
|
136
|
+
if (linearMatch) {
|
|
137
|
+
innerDeriv = linearMatch[1]!;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!innerDeriv) continue;
|
|
141
|
+
|
|
142
|
+
// Check if the RHS is missing the chain rule factor
|
|
143
|
+
if (outer === "sin") {
|
|
144
|
+
const wrongPattern = new RegExp(`^-?cos\\s*\\(\\s*${escapeRegex(inner)}\\s*\\)$`, "i");
|
|
145
|
+
if (wrongPattern.test(rhs.trim())) {
|
|
146
|
+
const expectedResult = `cos(${inner}) * ${innerDeriv}`;
|
|
147
|
+
return {
|
|
148
|
+
type: "chain_rule_error",
|
|
149
|
+
stepNumber: 0,
|
|
150
|
+
confidence: 0.9,
|
|
151
|
+
found: rhs,
|
|
152
|
+
expected: expectedResult,
|
|
153
|
+
explanation: `Chain rule error. When differentiating sin(f(x)), multiply by the derivative of the inner function.`,
|
|
154
|
+
suggestion: `d/dx sin(${inner}) = cos(${inner}) · (d/dx of ${inner}) = cos(${inner}) · ${innerDeriv}.`,
|
|
155
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (outer === "cos") {
|
|
161
|
+
const wrongPattern = new RegExp(`^-?sin\\s*\\(\\s*${escapeRegex(inner)}\\s*\\)$`, "i");
|
|
162
|
+
if (wrongPattern.test(rhs.trim())) {
|
|
163
|
+
const expectedResult = `-sin(${inner}) * ${innerDeriv}`;
|
|
164
|
+
return {
|
|
165
|
+
type: "chain_rule_error",
|
|
166
|
+
stepNumber: 0,
|
|
167
|
+
confidence: 0.9,
|
|
168
|
+
found: rhs,
|
|
169
|
+
expected: expectedResult,
|
|
170
|
+
explanation: `Chain rule error. When differentiating cos(f(x)), multiply by the derivative of the inner function.`,
|
|
171
|
+
suggestion: `d/dx cos(${inner}) = -sin(${inner}) · (d/dx of ${inner}) = -sin(${inner}) · ${innerDeriv}.`,
|
|
172
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (outer === "e^") {
|
|
178
|
+
const wrongPattern = new RegExp(`^e\\^\\s*\\(\\s*${escapeRegex(inner)}\\s*\\)$`, "i");
|
|
179
|
+
if (wrongPattern.test(rhs.trim())) {
|
|
180
|
+
const expectedResult = `e^(${inner}) * ${innerDeriv}`;
|
|
181
|
+
return {
|
|
182
|
+
type: "chain_rule_error",
|
|
183
|
+
stepNumber: 0,
|
|
184
|
+
confidence: 0.9,
|
|
185
|
+
found: rhs,
|
|
186
|
+
expected: expectedResult,
|
|
187
|
+
explanation: `Chain rule error. When differentiating e^(f(x)), multiply by the derivative of the inner function.`,
|
|
188
|
+
suggestion: `d/dx e^(${inner}) = e^(${inner}) · (d/dx of ${inner}) = e^(${inner}) · ${innerDeriv}.`,
|
|
189
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (outer === "power" && getExp) {
|
|
195
|
+
const n = getExp(match);
|
|
196
|
+
const wrongPattern = new RegExp(
|
|
197
|
+
`^${n}\\s*\\(\\s*${escapeRegex(inner)}\\s*\\)\\s*\\^\\s*${n - 1}$`,
|
|
198
|
+
"i",
|
|
199
|
+
);
|
|
200
|
+
if (wrongPattern.test(rhs.trim())) {
|
|
201
|
+
const expectedResult = `${n}(${inner})^${n - 1} * ${innerDeriv}`;
|
|
202
|
+
return {
|
|
203
|
+
type: "chain_rule_error",
|
|
204
|
+
stepNumber: 0,
|
|
205
|
+
confidence: 0.9,
|
|
206
|
+
found: rhs,
|
|
207
|
+
expected: expectedResult,
|
|
208
|
+
explanation: `Chain rule error. When differentiating (f(x))^n, multiply by the derivative of the inner function.`,
|
|
209
|
+
suggestion: `d/dx (${inner})^${n} = ${n}(${inner})^${n - 1} · (d/dx of ${inner}) = ${n}(${inner})^${n - 1} · ${innerDeriv}.`,
|
|
210
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Check for product rule error: d/dx (f * g) missing one term
|
|
221
|
+
*/
|
|
222
|
+
function checkProductRuleError(
|
|
223
|
+
lhs: string,
|
|
224
|
+
rhs: string,
|
|
225
|
+
_lhsAst: ASTNode | null,
|
|
226
|
+
_rhsAst: ASTNode | null,
|
|
227
|
+
): DetectedMistake | null {
|
|
228
|
+
const productPattern = /(?:d\/dx|derivative\s+of)\s+(.+?)\s*[*·]\s*(.+?)(?:\s*=|$)/i;
|
|
229
|
+
const match = lhs.match(productPattern);
|
|
230
|
+
|
|
231
|
+
if (!match) return null;
|
|
232
|
+
|
|
233
|
+
const f = match[1]!.trim();
|
|
234
|
+
const g = match[2]!.trim();
|
|
235
|
+
|
|
236
|
+
// Try to compute f' and g' for common cases
|
|
237
|
+
let fDeriv: string | null = null;
|
|
238
|
+
let gDeriv: string | null = null;
|
|
239
|
+
|
|
240
|
+
const computePowerDeriv = (expr: string): string | null => {
|
|
241
|
+
const powerMatch = expr.match(/^([a-zA-Z])\s*\^\s*(\d+)$/);
|
|
242
|
+
if (powerMatch) {
|
|
243
|
+
const v = powerMatch[1]!;
|
|
244
|
+
const n = parseInt(powerMatch[2]!, 10);
|
|
245
|
+
if (n === 1) return "1";
|
|
246
|
+
if (n === 2) return `2${v}`;
|
|
247
|
+
return `${n}${v}^${n - 1}`;
|
|
248
|
+
}
|
|
249
|
+
if (/^[a-zA-Z]$/.test(expr)) return "1";
|
|
250
|
+
return null;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const computeTrigExpDeriv = (expr: string): string | null => {
|
|
254
|
+
if (/^sin\s*\(\s*[a-zA-Z]\s*\)$/i.test(expr)) {
|
|
255
|
+
const v = expr.match(/\(([a-zA-Z])\)/)?.[1] || "x";
|
|
256
|
+
return `cos(${v})`;
|
|
257
|
+
}
|
|
258
|
+
if (/^cos\s*\(\s*[a-zA-Z]\s*\)$/i.test(expr)) {
|
|
259
|
+
const v = expr.match(/\(([a-zA-Z])\)/)?.[1] || "x";
|
|
260
|
+
return `-sin(${v})`;
|
|
261
|
+
}
|
|
262
|
+
if (/^e\s*\^\s*[a-zA-Z]$/i.test(expr)) {
|
|
263
|
+
return expr;
|
|
264
|
+
}
|
|
265
|
+
return null;
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
fDeriv = computePowerDeriv(f) ?? computeTrigExpDeriv(f);
|
|
269
|
+
gDeriv = computePowerDeriv(g) ?? computeTrigExpDeriv(g);
|
|
270
|
+
|
|
271
|
+
if (!fDeriv || !gDeriv) return null;
|
|
272
|
+
|
|
273
|
+
// Product rule: f'g + fg'
|
|
274
|
+
const term1 = fDeriv === "1" ? g : `${fDeriv} * ${g}`;
|
|
275
|
+
const term2 = gDeriv === "1" ? f : `${f} * ${gDeriv}`;
|
|
276
|
+
const expectedResult = `${term1} + ${term2}`;
|
|
277
|
+
|
|
278
|
+
const rhsNorm = rhs.replace(/\s*([*·+-])\s*/g, " $1 ").trim();
|
|
279
|
+
|
|
280
|
+
// Check if RHS is f' * g' (common mistake)
|
|
281
|
+
const isFPrimeGPrime =
|
|
282
|
+
!rhsNorm.includes("+") &&
|
|
283
|
+
rhsNorm.includes(fDeriv) &&
|
|
284
|
+
rhsNorm.includes(gDeriv) &&
|
|
285
|
+
!rhsNorm.includes(f) &&
|
|
286
|
+
!rhsNorm.includes(g);
|
|
287
|
+
|
|
288
|
+
if (isFPrimeGPrime) {
|
|
289
|
+
return {
|
|
290
|
+
type: "product_rule_error",
|
|
291
|
+
stepNumber: 0,
|
|
292
|
+
confidence: 0.9,
|
|
293
|
+
found: rhs,
|
|
294
|
+
expected: expectedResult,
|
|
295
|
+
explanation: `Product rule error. You cannot differentiate each factor separately and multiply. Use the product rule: (fg)' = f'g + fg'.`,
|
|
296
|
+
suggestion: `d/dx (${f} · ${g}) = (${fDeriv})·(${g}) + (${f})·(${gDeriv}) = ${term1} + ${term2}. You computed ${fDeriv} · ${gDeriv} = ${rhs}, which is wrong.`,
|
|
297
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Check if RHS contains only one of the terms
|
|
302
|
+
const hasOnlyTerm1 =
|
|
303
|
+
!rhsNorm.includes("+") &&
|
|
304
|
+
(rhsNorm.includes(fDeriv) || fDeriv === "1") &&
|
|
305
|
+
!rhsNorm.includes(gDeriv);
|
|
306
|
+
const hasOnlyTerm2 =
|
|
307
|
+
!rhsNorm.includes("+") &&
|
|
308
|
+
!rhsNorm.includes(fDeriv) &&
|
|
309
|
+
(rhsNorm.includes(gDeriv) || gDeriv === "1");
|
|
310
|
+
|
|
311
|
+
if (hasOnlyTerm1) {
|
|
312
|
+
return {
|
|
313
|
+
type: "product_rule_error",
|
|
314
|
+
stepNumber: 0,
|
|
315
|
+
confidence: 0.85,
|
|
316
|
+
found: rhs,
|
|
317
|
+
expected: expectedResult,
|
|
318
|
+
explanation: `Product rule error. When differentiating f·g, you need both f'·g AND f·g'.`,
|
|
319
|
+
suggestion: `d/dx (${f} · ${g}) = (${fDeriv})·(${g}) + (${f})·(${gDeriv}) = ${term1} + ${term2}. You're missing the ${term2} term.`,
|
|
320
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (hasOnlyTerm2) {
|
|
325
|
+
return {
|
|
326
|
+
type: "product_rule_error",
|
|
327
|
+
stepNumber: 0,
|
|
328
|
+
confidence: 0.85,
|
|
329
|
+
found: rhs,
|
|
330
|
+
expected: expectedResult,
|
|
331
|
+
explanation: `Product rule error. When differentiating f·g, you need both f'·g AND f·g'.`,
|
|
332
|
+
suggestion: `d/dx (${f} · ${g}) = (${fDeriv})·(${g}) + (${f})·(${gDeriv}) = ${term1} + ${term2}. You're missing the ${term1} term.`,
|
|
333
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Check for sign error: -a + b claimed equal to -(a + b) or similar
|
|
342
|
+
*/
|
|
343
|
+
function checkSignError(
|
|
344
|
+
lhs: string,
|
|
345
|
+
rhs: string,
|
|
346
|
+
lhsAst: ASTNode | null,
|
|
347
|
+
rhsAst: ASTNode | null,
|
|
348
|
+
): DetectedMistake | null {
|
|
349
|
+
if (!lhsAst || !rhsAst) return null;
|
|
350
|
+
|
|
351
|
+
// Check if negating the RHS makes it equal to LHS
|
|
352
|
+
const negatedRhs: ASTNode = { type: "unary", operator: "-", operand: rhsAst };
|
|
353
|
+
const negatedRhsStr = formatAST(negatedRhs, { spaces: true, minimalParens: true });
|
|
354
|
+
|
|
355
|
+
if (compareExpressions(lhs, negatedRhsStr)) {
|
|
356
|
+
const expectedVal = negatedRhsStr.replace(/^-\(/, "(").replace(/\)$/, ")");
|
|
357
|
+
return {
|
|
358
|
+
type: "sign_error",
|
|
359
|
+
stepNumber: 0,
|
|
360
|
+
confidence: 0.9,
|
|
361
|
+
found: rhs,
|
|
362
|
+
expected: expectedVal,
|
|
363
|
+
explanation: `Sign error detected. The expression '${rhs}' has the opposite sign of what was expected.`,
|
|
364
|
+
suggestion: "Check your negative signs. Remember that -(a + b) = -a - b, not -a + b.",
|
|
365
|
+
suggestedFix: `${lhs} = ${expectedVal}`,
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Check for common pattern: a - b written as b - a
|
|
370
|
+
if (lhsAst.type === "binary" && rhsAst.type === "binary") {
|
|
371
|
+
const lhsOp = normalizeOperator(lhsAst.operator);
|
|
372
|
+
const rhsOp = normalizeOperator(rhsAst.operator);
|
|
373
|
+
|
|
374
|
+
if (lhsOp === "-" && rhsOp === "-") {
|
|
375
|
+
if (nodesEqual(lhsAst.left, rhsAst.right) && nodesEqual(lhsAst.right, rhsAst.left)) {
|
|
376
|
+
return {
|
|
377
|
+
type: "sign_error",
|
|
378
|
+
stepNumber: 0,
|
|
379
|
+
confidence: 0.95,
|
|
380
|
+
found: rhs,
|
|
381
|
+
expected: lhs,
|
|
382
|
+
explanation: `Operands appear to be swapped in subtraction. Note that a - b ≠ b - a.`,
|
|
383
|
+
suggestion: "Subtraction is not commutative. Check the order of your operands.",
|
|
384
|
+
suggestedFix: `${lhs} = ${lhs}`,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
return null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Check for distribution error: a(b + c) ≠ ab + c
|
|
395
|
+
*/
|
|
396
|
+
function checkDistributionError(
|
|
397
|
+
lhs: string,
|
|
398
|
+
rhs: string,
|
|
399
|
+
lhsAst: ASTNode | null,
|
|
400
|
+
rhsAst: ASTNode | null,
|
|
401
|
+
): DetectedMistake | null {
|
|
402
|
+
if (!lhsAst || !rhsAst) return null;
|
|
403
|
+
|
|
404
|
+
if (lhsAst.type === "binary" && normalizeOperator(lhsAst.operator) === "*") {
|
|
405
|
+
const multiplier = lhsAst.left;
|
|
406
|
+
const inner = lhsAst.right;
|
|
407
|
+
|
|
408
|
+
if (inner.type === "binary" && (inner.operator === "+" || inner.operator === "-")) {
|
|
409
|
+
if (rhsAst.type === "binary" && (rhsAst.operator === "+" || rhsAst.operator === "-")) {
|
|
410
|
+
const leftIsProduct =
|
|
411
|
+
rhsAst.left.type === "binary" && normalizeOperator(rhsAst.left.operator) === "*";
|
|
412
|
+
const rightIsProduct =
|
|
413
|
+
rhsAst.right.type === "binary" && normalizeOperator(rhsAst.right.operator) === "*";
|
|
414
|
+
|
|
415
|
+
if (leftIsProduct !== rightIsProduct) {
|
|
416
|
+
const nonProduct = leftIsProduct ? rhsAst.right : rhsAst.left;
|
|
417
|
+
if (nodesEqual(nonProduct, inner.left) || nodesEqual(nonProduct, inner.right)) {
|
|
418
|
+
const mStr = formatAST(multiplier, { spaces: true });
|
|
419
|
+
const innerLeftStr = formatAST(inner.left, { spaces: true });
|
|
420
|
+
const innerRightStr = formatAST(inner.right, { spaces: true });
|
|
421
|
+
const correctRhs = `${mStr}*${innerLeftStr} ${inner.operator} ${mStr}*${innerRightStr}`;
|
|
422
|
+
return {
|
|
423
|
+
type: "distribution_error",
|
|
424
|
+
stepNumber: 0,
|
|
425
|
+
confidence: 0.85,
|
|
426
|
+
found: rhs,
|
|
427
|
+
expected: correctRhs,
|
|
428
|
+
explanation: `Incomplete distribution. When distributing, multiply ALL terms inside the parentheses.`,
|
|
429
|
+
suggestion: `Remember: a(b + c) = ab + ac, not ab + c. Distribute '${mStr}' to both terms.`,
|
|
430
|
+
suggestedFix: `${lhs} = ${correctRhs}`,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Flatten an addition/subtraction expression into terms with signs
|
|
443
|
+
*/
|
|
444
|
+
function flattenAddSubDistributed(
|
|
445
|
+
node: ASTNode,
|
|
446
|
+
positive = true,
|
|
447
|
+
): Array<{ node: ASTNode; positive: boolean }> {
|
|
448
|
+
if (node.type === "binary" && (node.operator === "+" || node.operator === "-")) {
|
|
449
|
+
const leftTerms = flattenAddSubDistributed(node.left, positive);
|
|
450
|
+
const rightPositive = node.operator === "+" ? positive : !positive;
|
|
451
|
+
const rightTerms = flattenAddSubDistributed(node.right, rightPositive);
|
|
452
|
+
return [...leftTerms, ...rightTerms];
|
|
453
|
+
}
|
|
454
|
+
return [{ node, positive }];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Check for subtraction distribution error: a - (b + c) = a - b + c instead of a - b - c
|
|
459
|
+
*/
|
|
460
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: nested subtraction detection requires deep AST traversal
|
|
461
|
+
function checkSubtractionDistributionError(
|
|
462
|
+
lhs: string,
|
|
463
|
+
rhs: string,
|
|
464
|
+
lhsAst: ASTNode | null,
|
|
465
|
+
rhsAst: ASTNode | null,
|
|
466
|
+
): DetectedMistake | null {
|
|
467
|
+
if (!lhsAst || !rhsAst) return null;
|
|
468
|
+
|
|
469
|
+
if (lhsAst.type === "binary" && normalizeOperator(lhsAst.operator) === "-") {
|
|
470
|
+
const outerLeft = lhsAst.left;
|
|
471
|
+
const innerGroup = lhsAst.right;
|
|
472
|
+
|
|
473
|
+
if (
|
|
474
|
+
innerGroup.type === "binary" &&
|
|
475
|
+
(innerGroup.operator === "+" || innerGroup.operator === "-")
|
|
476
|
+
) {
|
|
477
|
+
const lhsTerms = flattenAddSubDistributed(lhsAst);
|
|
478
|
+
const rhsTerms = flattenAddSubDistributed(rhsAst);
|
|
479
|
+
|
|
480
|
+
if (lhsTerms.length === rhsTerms.length && lhsTerms.length >= 2) {
|
|
481
|
+
let termsMismatch = false;
|
|
482
|
+
let signMismatch = false;
|
|
483
|
+
const signErrors: Array<{ term: string; found: string; expected: string }> = [];
|
|
484
|
+
|
|
485
|
+
for (let i = 0; i < lhsTerms.length; i++) {
|
|
486
|
+
const lhsTerm = lhsTerms[i]!;
|
|
487
|
+
const rhsTerm = rhsTerms[i]!;
|
|
488
|
+
|
|
489
|
+
if (!nodesEqual(lhsTerm.node, rhsTerm.node)) {
|
|
490
|
+
termsMismatch = true;
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
if (lhsTerm.positive !== rhsTerm.positive) {
|
|
494
|
+
signMismatch = true;
|
|
495
|
+
const termStr = formatAST(lhsTerm.node, { spaces: true, minimalParens: true });
|
|
496
|
+
signErrors.push({
|
|
497
|
+
term: termStr,
|
|
498
|
+
found: rhsTerm.positive ? "+" : "-",
|
|
499
|
+
expected: lhsTerm.positive ? "+" : "-",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!termsMismatch && signMismatch && signErrors.length > 0) {
|
|
505
|
+
const errorDetails = signErrors
|
|
506
|
+
.map((e) => `'${e.term}' has '${e.found}' but should have '${e.expected}'`)
|
|
507
|
+
.join("; ");
|
|
508
|
+
|
|
509
|
+
const hasNestedGroup =
|
|
510
|
+
innerGroup.type === "binary" &&
|
|
511
|
+
(innerGroup.left.type === "binary" || innerGroup.right.type === "binary");
|
|
512
|
+
const nestedNote = hasNestedGroup
|
|
513
|
+
? " With nested parentheses, distribute the negative through each level."
|
|
514
|
+
: "";
|
|
515
|
+
|
|
516
|
+
const correctTerms = lhsTerms
|
|
517
|
+
.map((t, i) => {
|
|
518
|
+
const termStr = formatAST(t.node, { spaces: true, minimalParens: true });
|
|
519
|
+
if (i === 0) return t.positive ? termStr : `-${termStr}`;
|
|
520
|
+
return t.positive ? ` + ${termStr}` : ` - ${termStr}`;
|
|
521
|
+
})
|
|
522
|
+
.join("");
|
|
523
|
+
|
|
524
|
+
return {
|
|
525
|
+
type: "subtraction_distribution_error",
|
|
526
|
+
stepNumber: 0,
|
|
527
|
+
confidence: 0.95,
|
|
528
|
+
found: rhs,
|
|
529
|
+
expected: correctTerms,
|
|
530
|
+
explanation: `Subtraction distribution error. When subtracting a group, distribute the negative to ALL terms inside.${nestedNote}`,
|
|
531
|
+
suggestion: `Sign error: ${errorDetails}. Remember: -(a + b) = -a - b and -(-a) = +a.`,
|
|
532
|
+
suggestedFix: `${lhs} = ${correctTerms}`,
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Fallback check
|
|
538
|
+
if (rhsAst.type === "binary") {
|
|
539
|
+
const innerOp = innerGroup.operator;
|
|
540
|
+
const correctSecondOp = innerOp === "+" ? "-" : "+";
|
|
541
|
+
const foundSecondOp = normalizeOperator(rhsAst.operator);
|
|
542
|
+
|
|
543
|
+
if (rhsAst.left.type === "binary" && normalizeOperator(rhsAst.left.operator) === "-") {
|
|
544
|
+
const rhsOuterLeft = rhsAst.left.left;
|
|
545
|
+
const rhsFirstInner = rhsAst.left.right;
|
|
546
|
+
const rhsSecondInner = rhsAst.right;
|
|
547
|
+
|
|
548
|
+
if (
|
|
549
|
+
nodesEqual(outerLeft, rhsOuterLeft) &&
|
|
550
|
+
nodesEqual(innerGroup.left, rhsFirstInner) &&
|
|
551
|
+
nodesEqual(innerGroup.right, rhsSecondInner)
|
|
552
|
+
) {
|
|
553
|
+
if (foundSecondOp !== correctSecondOp) {
|
|
554
|
+
const wrongSign = foundSecondOp === "+" ? "+" : "-";
|
|
555
|
+
const correctSign = correctSecondOp;
|
|
556
|
+
const outerStr = formatAST(outerLeft, { spaces: true });
|
|
557
|
+
const firstInnerStr = formatAST(innerGroup.left, { spaces: true });
|
|
558
|
+
const secondInnerStr = formatAST(innerGroup.right, { spaces: true });
|
|
559
|
+
const correctRhs = `${outerStr} - ${firstInnerStr} ${correctSign} ${secondInnerStr}`;
|
|
560
|
+
return {
|
|
561
|
+
type: "subtraction_distribution_error",
|
|
562
|
+
stepNumber: 0,
|
|
563
|
+
confidence: 0.95,
|
|
564
|
+
found: rhs,
|
|
565
|
+
expected: correctRhs,
|
|
566
|
+
explanation: `Subtraction distribution error. When subtracting a group, distribute the negative to ALL terms inside.`,
|
|
567
|
+
suggestion: `Remember: a - (b + c) = a - b - c, not a - b + c. The '${wrongSign}' should be '${correctSign}'.`,
|
|
568
|
+
suggestedFix: `${lhs} = ${correctRhs}`,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Check for cancellation error: (a + b)/a ≠ b
|
|
582
|
+
*/
|
|
583
|
+
function checkCancellationError(
|
|
584
|
+
lhs: string,
|
|
585
|
+
rhs: string,
|
|
586
|
+
lhsAst: ASTNode | null,
|
|
587
|
+
rhsAst: ASTNode | null,
|
|
588
|
+
): DetectedMistake | null {
|
|
589
|
+
if (!lhsAst || !rhsAst) return null;
|
|
590
|
+
|
|
591
|
+
if (lhsAst.type === "binary" && normalizeOperator(lhsAst.operator) === "/") {
|
|
592
|
+
const numerator = lhsAst.left;
|
|
593
|
+
const denominator = lhsAst.right;
|
|
594
|
+
|
|
595
|
+
if (numerator.type === "binary" && (numerator.operator === "+" || numerator.operator === "-")) {
|
|
596
|
+
if (
|
|
597
|
+
nodesEqual(rhsAst, numerator.left) ||
|
|
598
|
+
nodesEqual(rhsAst, numerator.right) ||
|
|
599
|
+
nodesEqual(rhsAst, denominator)
|
|
600
|
+
) {
|
|
601
|
+
const aStr = formatAST(numerator.left, { spaces: true });
|
|
602
|
+
const bStr = formatAST(numerator.right, { spaces: true });
|
|
603
|
+
const cStr = formatAST(denominator, { spaces: true });
|
|
604
|
+
const correctRhs = `${aStr}/${cStr} ${numerator.operator} ${bStr}/${cStr}`;
|
|
605
|
+
return {
|
|
606
|
+
type: "cancellation_error",
|
|
607
|
+
stepNumber: 0,
|
|
608
|
+
confidence: 0.8,
|
|
609
|
+
found: rhs,
|
|
610
|
+
expected: correctRhs,
|
|
611
|
+
explanation: `Invalid cancellation. You cannot cancel terms that are being added/subtracted in the numerator with the denominator.`,
|
|
612
|
+
suggestion: `Remember: (a + b)/c ≠ b. You can only cancel common FACTORS, not terms. Try: (a + b)/c = a/c + b/c.`,
|
|
613
|
+
suggestedFix: `${lhs} = ${correctRhs}`,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Check for coefficient error: 2x + 3x = 6x instead of 5x
|
|
624
|
+
*/
|
|
625
|
+
function checkCoefficientError(
|
|
626
|
+
lhs: string,
|
|
627
|
+
rhs: string,
|
|
628
|
+
_lhsAst: ASTNode | null,
|
|
629
|
+
_rhsAst: ASTNode | null,
|
|
630
|
+
): DetectedMistake | null {
|
|
631
|
+
const termPattern = /([+-]?)\s*(\d*)([a-zA-Z])(?!\^|\d)/g;
|
|
632
|
+
|
|
633
|
+
const lhsTerms: Array<{ coeff: number; variable: string; sign: number }> = [];
|
|
634
|
+
let match: RegExpExecArray | null;
|
|
635
|
+
let isFirst = true;
|
|
636
|
+
while ((match = termPattern.exec(lhs)) !== null) {
|
|
637
|
+
const signStr = match[1] || "";
|
|
638
|
+
const coeffStr = match[2] ?? "";
|
|
639
|
+
const coeff = coeffStr === "" ? 1 : parseInt(coeffStr, 10);
|
|
640
|
+
const variable = match[3]!;
|
|
641
|
+
const sign = signStr === "-" ? -1 : 1;
|
|
642
|
+
lhsTerms.push({ coeff, variable, sign: isFirst && signStr === "" ? 1 : sign });
|
|
643
|
+
isFirst = false;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const rhsTermPattern = /([+-]?)\s*(\d*)([a-zA-Z])(?!\^|\d)/g;
|
|
647
|
+
const rhsTerms: Array<{ coeff: number; variable: string; sign: number }> = [];
|
|
648
|
+
let rhsFirst = true;
|
|
649
|
+
while ((match = rhsTermPattern.exec(rhs)) !== null) {
|
|
650
|
+
const signStr = match[1] || "";
|
|
651
|
+
const coeffStr = match[2] ?? "";
|
|
652
|
+
const coeff = coeffStr === "" ? 1 : parseInt(coeffStr, 10);
|
|
653
|
+
const variable = match[3]!;
|
|
654
|
+
const sign = signStr === "-" ? -1 : 1;
|
|
655
|
+
rhsTerms.push({ coeff, variable, sign: rhsFirst && signStr === "" ? 1 : sign });
|
|
656
|
+
rhsFirst = false;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (lhsTerms.length >= 2 && rhsTerms.length === 1) {
|
|
660
|
+
const rhsTerm = rhsTerms[0]!;
|
|
661
|
+
const rhsCoeff = rhsTerm.coeff * rhsTerm.sign;
|
|
662
|
+
const rhsVar = rhsTerm.variable;
|
|
663
|
+
|
|
664
|
+
if (rhsVar && lhsTerms.every((t) => t.variable === rhsVar)) {
|
|
665
|
+
const lhsCoeffs = lhsTerms.map((t) => t.coeff * t.sign);
|
|
666
|
+
const expectedSum = lhsCoeffs.reduce((a, b) => a + b, 0);
|
|
667
|
+
const absCoeffs = lhsTerms.map((t) => t.coeff);
|
|
668
|
+
const possibleProduct = absCoeffs.reduce((a, b) => a * b, 1);
|
|
669
|
+
|
|
670
|
+
if (rhsCoeff === possibleProduct && rhsCoeff !== expectedSum) {
|
|
671
|
+
const expectedResult = `${expectedSum}${rhsVar}`;
|
|
672
|
+
return {
|
|
673
|
+
type: "coefficient_error",
|
|
674
|
+
stepNumber: 0,
|
|
675
|
+
confidence: 0.85,
|
|
676
|
+
found: rhs,
|
|
677
|
+
expected: expectedResult,
|
|
678
|
+
explanation: `Coefficient error. When combining like terms, ADD the coefficients, don't multiply them.`,
|
|
679
|
+
suggestion: `${absCoeffs.join(" × ")} = ${possibleProduct}, but you should ADD: ${lhsCoeffs.map((c, i) => (i === 0 ? c : c >= 0 ? `+ ${c}` : `- ${Math.abs(c)}`)).join(" ")} = ${expectedSum}. So the answer should be ${expectedSum}${rhsVar}.`,
|
|
680
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
681
|
+
};
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (rhsCoeff !== expectedSum && absCoeffs.includes(Math.abs(rhsCoeff))) {
|
|
685
|
+
const expectedResult = `${expectedSum}${rhsVar}`;
|
|
686
|
+
return {
|
|
687
|
+
type: "coefficient_error",
|
|
688
|
+
stepNumber: 0,
|
|
689
|
+
confidence: 0.8,
|
|
690
|
+
found: rhs,
|
|
691
|
+
expected: expectedResult,
|
|
692
|
+
explanation: `Coefficient error. The result ${rhsCoeff}${rhsVar} is one of the original coefficients, not the combined result.`,
|
|
693
|
+
suggestion: `When combining like terms: ${lhsTerms.map((t, i) => (i === 0 ? `${t.coeff}${t.variable}` : `${t.sign >= 0 ? "+" : "-"} ${t.coeff}${t.variable}`)).join(" ")} = ${expectedSum}${rhsVar}, not ${rhsCoeff}${rhsVar}.`,
|
|
694
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (rhsCoeff !== expectedSum && Math.abs(rhsCoeff - expectedSum) <= Math.max(...absCoeffs)) {
|
|
699
|
+
const expectedResult = `${expectedSum}${rhsVar}`;
|
|
700
|
+
return {
|
|
701
|
+
type: "coefficient_error",
|
|
702
|
+
stepNumber: 0,
|
|
703
|
+
confidence: 0.75,
|
|
704
|
+
found: rhs,
|
|
705
|
+
expected: expectedResult,
|
|
706
|
+
explanation: `Coefficient error when combining like terms.`,
|
|
707
|
+
suggestion: `${lhsTerms.map((t, i) => (i === 0 ? `${t.coeff}${t.variable}` : `${t.sign >= 0 ? "+" : "-"} ${t.coeff}${t.variable}`)).join(" ")} = ${expectedSum}${rhsVar}.`,
|
|
708
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Check for exponent error: x^2 * x^3 = x^6 instead of x^5
|
|
719
|
+
*/
|
|
720
|
+
function checkExponentError(
|
|
721
|
+
lhs: string,
|
|
722
|
+
rhs: string,
|
|
723
|
+
lhsAst: ASTNode | null,
|
|
724
|
+
rhsAst: ASTNode | null,
|
|
725
|
+
): DetectedMistake | null {
|
|
726
|
+
if (!lhsAst || !rhsAst) return null;
|
|
727
|
+
|
|
728
|
+
if (lhsAst.type === "binary" && normalizeOperator(lhsAst.operator) === "*") {
|
|
729
|
+
const left = lhsAst.left;
|
|
730
|
+
const right = lhsAst.right;
|
|
731
|
+
|
|
732
|
+
if (
|
|
733
|
+
left.type === "binary" &&
|
|
734
|
+
right.type === "binary" &&
|
|
735
|
+
normalizeOperator(left.operator) === "^" &&
|
|
736
|
+
normalizeOperator(right.operator) === "^"
|
|
737
|
+
) {
|
|
738
|
+
if (nodesEqual(left.left, right.left)) {
|
|
739
|
+
if (
|
|
740
|
+
left.right.type === "number" &&
|
|
741
|
+
right.right.type === "number" &&
|
|
742
|
+
rhsAst.type === "binary" &&
|
|
743
|
+
normalizeOperator(rhsAst.operator) === "^" &&
|
|
744
|
+
rhsAst.right.type === "number"
|
|
745
|
+
) {
|
|
746
|
+
const exp1 = left.right.value;
|
|
747
|
+
const exp2 = right.right.value;
|
|
748
|
+
const resultExp = rhsAst.right.value;
|
|
749
|
+
const expectedSum = exp1 + exp2;
|
|
750
|
+
const possibleProduct = exp1 * exp2;
|
|
751
|
+
|
|
752
|
+
if (resultExp === possibleProduct && resultExp !== expectedSum) {
|
|
753
|
+
const baseStr = formatAST(left.left, { spaces: false });
|
|
754
|
+
const expectedResult = `${baseStr}^${expectedSum}`;
|
|
755
|
+
return {
|
|
756
|
+
type: "exponent_error",
|
|
757
|
+
stepNumber: 0,
|
|
758
|
+
confidence: 0.9,
|
|
759
|
+
found: rhs,
|
|
760
|
+
expected: expectedResult,
|
|
761
|
+
explanation: `Exponent error. When multiplying powers with the same base, ADD the exponents.`,
|
|
762
|
+
suggestion: `${baseStr}^${exp1} × ${baseStr}^${exp2} = ${baseStr}^(${exp1}+${exp2}) = ${baseStr}^${expectedSum}, not ${baseStr}^${possibleProduct}.`,
|
|
763
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Check for power rule derivative error: d/dx of x^n = nx^n instead of nx^(n-1)
|
|
776
|
+
*/
|
|
777
|
+
function checkPowerRuleError(
|
|
778
|
+
lhs: string,
|
|
779
|
+
rhs: string,
|
|
780
|
+
_lhsAst: ASTNode | null,
|
|
781
|
+
rhsAst: ASTNode | null,
|
|
782
|
+
): DetectedMistake | null {
|
|
783
|
+
const derivativePattern = /(?:d\/dx|derivative\s+of|diff(?:erentiate)?)\s*(?:of\s+)?(\w)\^(\d+)/i;
|
|
784
|
+
const match = lhs.match(derivativePattern);
|
|
785
|
+
|
|
786
|
+
if (!match) return null;
|
|
787
|
+
|
|
788
|
+
const variable = match[1]!;
|
|
789
|
+
const originalExp = parseInt(match[2]!, 10);
|
|
790
|
+
const expectedCoeff = originalExp;
|
|
791
|
+
const expectedExp = originalExp - 1;
|
|
792
|
+
|
|
793
|
+
const resultPattern = new RegExp(`(\\d+)${variable}\\^(\\d+)`, "i");
|
|
794
|
+
const resultMatch = rhs.match(resultPattern);
|
|
795
|
+
|
|
796
|
+
if (resultMatch) {
|
|
797
|
+
const resultCoeff = parseInt(resultMatch[1]!, 10);
|
|
798
|
+
const resultExp = parseInt(resultMatch[2]!, 10);
|
|
799
|
+
|
|
800
|
+
if (resultCoeff === expectedCoeff && resultExp === originalExp) {
|
|
801
|
+
const expectedResult = `${expectedCoeff}${variable}^${expectedExp}`;
|
|
802
|
+
return {
|
|
803
|
+
type: "power_rule_error",
|
|
804
|
+
stepNumber: 0,
|
|
805
|
+
confidence: 0.95,
|
|
806
|
+
found: rhs,
|
|
807
|
+
expected: expectedResult,
|
|
808
|
+
explanation: `Power rule error. When differentiating x^n, the exponent decreases by 1.`,
|
|
809
|
+
suggestion: `d/dx of ${variable}^${originalExp} = ${originalExp}·${variable}^(${originalExp}-1) = ${expectedCoeff}${variable}^${expectedExp}, not ${resultCoeff}${variable}^${resultExp}.`,
|
|
810
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
if (resultCoeff === 1 && resultExp === expectedExp) {
|
|
815
|
+
const noCoeffPattern = new RegExp(`^${variable}\\^${expectedExp}$`, "i");
|
|
816
|
+
if (noCoeffPattern.test(rhs.trim())) {
|
|
817
|
+
const expectedResult = `${expectedCoeff}${variable}^${expectedExp}`;
|
|
818
|
+
return {
|
|
819
|
+
type: "power_rule_error",
|
|
820
|
+
stepNumber: 0,
|
|
821
|
+
confidence: 0.85,
|
|
822
|
+
found: rhs,
|
|
823
|
+
expected: expectedResult,
|
|
824
|
+
explanation: `Power rule error. Don't forget to multiply by the original exponent.`,
|
|
825
|
+
suggestion: `d/dx of ${variable}^${originalExp} = ${originalExp}·${variable}^(${originalExp}-1) = ${expectedCoeff}${variable}^${expectedExp}. You got the exponent right but forgot the coefficient ${originalExp}.`,
|
|
826
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (originalExp === 2 && rhsAst?.type === "variable") {
|
|
833
|
+
const expectedResult = `2${variable}`;
|
|
834
|
+
return {
|
|
835
|
+
type: "power_rule_error",
|
|
836
|
+
stepNumber: 0,
|
|
837
|
+
confidence: 0.8,
|
|
838
|
+
found: rhs,
|
|
839
|
+
expected: expectedResult,
|
|
840
|
+
explanation: `Power rule error. Don't forget to multiply by the original exponent.`,
|
|
841
|
+
suggestion: `d/dx of ${variable}^2 = 2·${variable}^(2-1) = 2${variable}, not just ${variable}.`,
|
|
842
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
return null;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Check for fraction addition error: 1/2 + 1/3 = 2/5 instead of 5/6
|
|
851
|
+
*/
|
|
852
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: fraction error detection requires extensive pattern matching
|
|
853
|
+
function checkFractionAdditionError(
|
|
854
|
+
lhs: string,
|
|
855
|
+
rhs: string,
|
|
856
|
+
lhsAst: ASTNode | null,
|
|
857
|
+
rhsAst: ASTNode | null,
|
|
858
|
+
): DetectedMistake | null {
|
|
859
|
+
if (!lhsAst || !rhsAst) return null;
|
|
860
|
+
|
|
861
|
+
if (lhsAst.type === "binary" && normalizeOperator(lhsAst.operator) === "+") {
|
|
862
|
+
const left = lhsAst.left;
|
|
863
|
+
const right = lhsAst.right;
|
|
864
|
+
|
|
865
|
+
if (
|
|
866
|
+
left.type === "binary" &&
|
|
867
|
+
right.type === "binary" &&
|
|
868
|
+
normalizeOperator(left.operator) === "/" &&
|
|
869
|
+
normalizeOperator(right.operator) === "/"
|
|
870
|
+
) {
|
|
871
|
+
const a = left.left;
|
|
872
|
+
const b = left.right;
|
|
873
|
+
const c = right.left;
|
|
874
|
+
const d = right.right;
|
|
875
|
+
|
|
876
|
+
if (rhsAst.type === "binary" && normalizeOperator(rhsAst.operator) === "/") {
|
|
877
|
+
const resultNum = rhsAst.left;
|
|
878
|
+
const resultDen = rhsAst.right;
|
|
879
|
+
|
|
880
|
+
if (
|
|
881
|
+
a.type === "number" &&
|
|
882
|
+
b.type === "number" &&
|
|
883
|
+
c.type === "number" &&
|
|
884
|
+
d.type === "number" &&
|
|
885
|
+
resultNum.type === "number" &&
|
|
886
|
+
resultDen.type === "number"
|
|
887
|
+
) {
|
|
888
|
+
const aVal = a.value;
|
|
889
|
+
const bVal = b.value;
|
|
890
|
+
const cVal = c.value;
|
|
891
|
+
const dVal = d.value;
|
|
892
|
+
const wrongNum = aVal + cVal;
|
|
893
|
+
const wrongDen = bVal + dVal;
|
|
894
|
+
|
|
895
|
+
if (resultNum.value === wrongNum && resultDen.value === wrongDen) {
|
|
896
|
+
const correctNum = aVal * dVal + bVal * cVal;
|
|
897
|
+
const correctDen = bVal * dVal;
|
|
898
|
+
const g = gcd(Math.abs(correctNum), Math.abs(correctDen));
|
|
899
|
+
const simplifiedNum = correctNum / g;
|
|
900
|
+
const simplifiedDen = correctDen / g;
|
|
901
|
+
|
|
902
|
+
const expectedResult =
|
|
903
|
+
simplifiedDen === 1 ? `${simplifiedNum}` : `${simplifiedNum}/${simplifiedDen}`;
|
|
904
|
+
return {
|
|
905
|
+
type: "fraction_error",
|
|
906
|
+
stepNumber: 0,
|
|
907
|
+
confidence: 0.95,
|
|
908
|
+
found: rhs,
|
|
909
|
+
expected: expectedResult,
|
|
910
|
+
explanation: `Fraction addition error. You cannot add fractions by adding numerators and denominators separately.`,
|
|
911
|
+
suggestion: `${aVal}/${bVal} + ${cVal}/${dVal} requires a common denominator. The correct calculation is (${aVal}×${dVal} + ${bVal}×${cVal})/(${bVal}×${dVal}) = ${correctNum}/${correctDen} = ${simplifiedNum}/${simplifiedDen}.`,
|
|
912
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (resultNum.type === "binary" && resultDen.type === "binary") {
|
|
918
|
+
const numOp = normalizeOperator(resultNum.operator);
|
|
919
|
+
const denOp = normalizeOperator(resultDen.operator);
|
|
920
|
+
|
|
921
|
+
if (numOp === "+" && denOp === "+") {
|
|
922
|
+
const numLeft = resultNum.left;
|
|
923
|
+
const numRight = (resultNum as BinaryNode).right;
|
|
924
|
+
const denLeft = resultDen.left;
|
|
925
|
+
const denRight = (resultDen as BinaryNode).right;
|
|
926
|
+
|
|
927
|
+
const numMatchesAC =
|
|
928
|
+
(nodesEqual(numLeft, a) && nodesEqual(numRight, c)) ||
|
|
929
|
+
(nodesEqual(numLeft, c) && nodesEqual(numRight, a));
|
|
930
|
+
const denMatchesBD =
|
|
931
|
+
(nodesEqual(denLeft, b) && nodesEqual(denRight, d)) ||
|
|
932
|
+
(nodesEqual(denLeft, d) && nodesEqual(denRight, b));
|
|
933
|
+
|
|
934
|
+
if (numMatchesAC && denMatchesBD) {
|
|
935
|
+
const aStr = formatAST(a, { spaces: false });
|
|
936
|
+
const bStr = formatAST(b, { spaces: false });
|
|
937
|
+
const cStr = formatAST(c, { spaces: false });
|
|
938
|
+
const dStr = formatAST(d, { spaces: false });
|
|
939
|
+
|
|
940
|
+
const expectedResult = `(${aStr}·${dStr} + ${bStr}·${cStr})/(${bStr}·${dStr})`;
|
|
941
|
+
return {
|
|
942
|
+
type: "fraction_error",
|
|
943
|
+
stepNumber: 0,
|
|
944
|
+
confidence: 0.9,
|
|
945
|
+
found: rhs,
|
|
946
|
+
expected: expectedResult,
|
|
947
|
+
explanation: `Fraction addition error. You cannot add fractions by adding numerators and denominators separately.`,
|
|
948
|
+
suggestion: `${aStr}/${bStr} + ${cStr}/${dStr} = (${aStr}·${dStr} + ${bStr}·${cStr})/(${bStr}·${dStr}), not (${aStr}+${cStr})/(${bStr}+${dStr}).`,
|
|
949
|
+
suggestedFix: `${lhs} = ${expectedResult}`,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
return null;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Detect common algebraic mistakes in a derivation
|
|
963
|
+
*
|
|
964
|
+
* Analyzes each step of a derivation looking for patterns that indicate
|
|
965
|
+
* common student errors like sign mistakes, distribution errors, etc.
|
|
966
|
+
*
|
|
967
|
+
* @param steps Array of {lhs, rhs} pairs representing the derivation
|
|
968
|
+
* @returns MistakeDetectionResult with identified mistakes and suggestions
|
|
969
|
+
*/
|
|
970
|
+
export function detectCommonMistakes(
|
|
971
|
+
steps: Array<{ lhs: string; rhs: string }>,
|
|
972
|
+
): MistakeDetectionResult {
|
|
973
|
+
const mistakes: DetectedMistake[] = [];
|
|
974
|
+
|
|
975
|
+
for (let i = 0; i < steps.length; i++) {
|
|
976
|
+
const step = steps[i];
|
|
977
|
+
if (!step) continue;
|
|
978
|
+
|
|
979
|
+
const { lhs, rhs } = step;
|
|
980
|
+
const stepNum = i + 1;
|
|
981
|
+
|
|
982
|
+
// Skip if expressions are actually equivalent (no error)
|
|
983
|
+
if (compareExpressions(lhs, rhs)) {
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
// Parse both sides
|
|
988
|
+
const lhsAst = parseToAST(lhs);
|
|
989
|
+
const rhsAst = parseToAST(rhs);
|
|
990
|
+
|
|
991
|
+
// Run all mistake detectors
|
|
992
|
+
const checkers = [
|
|
993
|
+
checkSignError,
|
|
994
|
+
checkSubtractionDistributionError,
|
|
995
|
+
checkDistributionError,
|
|
996
|
+
checkCancellationError,
|
|
997
|
+
checkCoefficientError,
|
|
998
|
+
checkExponentError,
|
|
999
|
+
checkPowerRuleError,
|
|
1000
|
+
checkChainRuleError,
|
|
1001
|
+
checkProductRuleError,
|
|
1002
|
+
checkFractionAdditionError,
|
|
1003
|
+
];
|
|
1004
|
+
|
|
1005
|
+
for (const checker of checkers) {
|
|
1006
|
+
const mistake = checker(lhs, rhs, lhsAst, rhsAst);
|
|
1007
|
+
if (mistake) {
|
|
1008
|
+
mistake.stepNumber = stepNum;
|
|
1009
|
+
mistakes.push(mistake);
|
|
1010
|
+
break; // Only report one mistake per step
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Generate summary
|
|
1016
|
+
let summary: string;
|
|
1017
|
+
if (mistakes.length === 0) {
|
|
1018
|
+
summary = "No common mistakes detected.";
|
|
1019
|
+
} else if (mistakes.length === 1) {
|
|
1020
|
+
const m = mistakes[0]!;
|
|
1021
|
+
summary = `Found 1 potential mistake at step ${m.stepNumber}: ${m.type.replace(/_/g, " ")}`;
|
|
1022
|
+
} else {
|
|
1023
|
+
const types = [...new Set(mistakes.map((m) => m.type.replace(/_/g, " ")))];
|
|
1024
|
+
summary = `Found ${mistakes.length} potential mistakes: ${types.join(", ")}`;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return {
|
|
1028
|
+
hasMistakes: mistakes.length > 0,
|
|
1029
|
+
mistakes,
|
|
1030
|
+
summary,
|
|
1031
|
+
};
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Detect common mistakes from text containing a derivation
|
|
1036
|
+
*
|
|
1037
|
+
* @param text Text containing a derivation
|
|
1038
|
+
* @returns MistakeDetectionResult or null if no derivation found
|
|
1039
|
+
*/
|
|
1040
|
+
export function detectCommonMistakesFromText(text: string): MistakeDetectionResult | null {
|
|
1041
|
+
const steps = extractDerivationSteps(text);
|
|
1042
|
+
if (steps.length === 0) {
|
|
1043
|
+
return null;
|
|
1044
|
+
}
|
|
1045
|
+
return detectCommonMistakes(steps);
|
|
1046
|
+
}
|