Skip to content

Commit 4592a16

Browse files
committed
fixed #290
1 parent 8711103 commit 4592a16

8 files changed

Lines changed: 166 additions & 90 deletions

File tree

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
## [Unreleased]
22

3+
### Bug Fixes
4+
5+
- **Derivatives of user-defined functions** (#290): `\frac{d}{dx} f` and `f'(x)`
6+
now correctly evaluate when `f` is a user-defined function (e.g.,
7+
`f(x) := 2x`). Previously `\frac{d}{dx} f` returned `0` and `f'(x)` returned
8+
a symbolic `Apply(Derivative(...))`.
9+
- **Stack overflow with same-name arguments**: Evaluating `f(x)` no longer
10+
causes a stack overflow when the argument name matches the parameter name.
11+
- **Cleaner `D` canonical form**: `f'(x)` now canonicalizes to
12+
`["D", ["f", "x"], "x"]` instead of the verbose
13+
`["D", ["Function", ["Block", ["f", "x"]], "x"], "x"]`. Function calls are
14+
no longer redundantly wrapped in `Function(Block(...))`. Similarly,
15+
`\frac{d}{dx} f` where `f` is a known function symbol canonicalizes to
16+
`["D", ["f", "x"], "x"]` by applying the function to the differentiation
17+
variable.
18+
319
### Free Functions
420

521
- Free functions (`simplify`, `evaluate`, `N`, `expand`, `expandAll`, `factor`,

src/compute-engine/boxed-expression/boxed-symbol.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,14 @@ export class BoxedSymbol extends _BoxedExpression implements SymbolInterface {
402402
if (this._def.value.isConstant) return this._def.value.value;
403403

404404
// Lookup the value in the current evaluation context
405-
return this.engine._getSymbolValue(this._id);
405+
const result = this.engine._getSymbolValue(this._id);
406+
407+
// Guard: when a function parameter is bound to an argument with the same
408+
// name (e.g., f(x) called with argument x), the eval context maps x → x,
409+
// creating an infinite loop. Return undefined to treat as a free variable.
410+
if (result !== undefined && result.symbol === this._id) return undefined;
411+
412+
return result;
406413
}
407414

408415
get value(): Expression | undefined {

src/compute-engine/library/calculus.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Expression, SymbolDefinitions } from '../global-types';
22

33
import { checkType } from '../boxed-expression/validate';
44
import { hasSymbolicTranscendental } from '../boxed-expression/utils';
5-
import { isFunction, sym } from '../boxed-expression/type-guards';
5+
import { isFunction, isSymbol, sym } from '../boxed-expression/type-guards';
66

77
import {
88
applicableN1,
@@ -140,14 +140,39 @@ volumes
140140
signature:
141141
'(expression, variable:symbol, variables:symbol+) -> expression',
142142
canonical: (ops, { engine: ce, scope }) => {
143+
// If the first argument is a function symbol (e.g., f where f(x):=2x),
144+
// apply it to the differentiation variables to produce a function call.
145+
// e.g., ['D', 'f', 'x'] → ['D', ['f', 'x'], 'x']
146+
if (isSymbol(ops[0]) && ops[0].canonical.operatorDefinition) {
147+
const vars = ops.slice(1);
148+
const fCall = ce.function(ops[0].symbol, vars);
149+
return ce._fn('D', [fCall, ...vars], { scope });
150+
}
151+
152+
// If the first argument is already a function call (e.g., f'(x)
153+
// parsed as ['D', ['f', 'x'], 'x']), use it directly rather than
154+
// wrapping in Function(Block(...)).
155+
const op0 = ops[0].canonical;
156+
if (isFunction(op0) && op0.operator) {
157+
return ce._fn('D', [op0, ...ops.slice(1)], { scope });
158+
}
159+
143160
const f = canonicalFunctionLiteralArguments(ce, ops);
144161
if (!f) return null;
145162

146163
return ce._fn('D', [f, ...ops!.slice(1)], { scope });
147164
},
148165
evaluate: (ops, { engine: _engine }) => {
149166
let f: Expression | undefined = ops[0].canonical;
150-
f = f.evaluate();
167+
168+
// Unwrap Function literals to get the body for differentiation.
169+
// For non-Function expressions (e.g., ['f', 'x']), do NOT call
170+
// .evaluate() before differentiating — that would prematurely
171+
// substitute variable values (e.g., x=5) and lose structural info.
172+
if (f.operator === 'Function' && isFunction(f)) {
173+
f = f.op1;
174+
}
175+
151176
const params = ops.slice(1);
152177
if (params.length === 0) f = undefined;
153178
for (const param of params) {
@@ -156,7 +181,6 @@ volumes
156181
f = undefined;
157182
break;
158183
}
159-
if (f && f.operator === 'Function' && isFunction(f)) f = f.op1;
160184
f = differentiate(f!, paramSym);
161185
if (f === undefined) break;
162186
}

src/compute-engine/symbolic/derivative.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,19 @@ export function differentiate(
233233
if (isNumber(expr)) return expr.engine.Zero;
234234
if (isSymbol(expr)) {
235235
if (expr.symbol === v) return expr.engine.One;
236+
237+
// Resolve user-defined functions: e.g. f where f(x) := 2x
238+
if (expr.operatorDefinition) {
239+
const ce = expr.engine;
240+
const wildcard = ce.symbol('_');
241+
const body = ce.function(expr.symbol, [wildcard]).evaluate();
242+
// If the body resolved (is not just the same function call), differentiate it
243+
if (body.operator !== expr.symbol) {
244+
const bodyWithV = body.subs({ _: ce.symbol(v) });
245+
return differentiate(bodyWithV, v, depth + 1);
246+
}
247+
}
248+
236249
return expr.engine.Zero;
237250
}
238251
if (!expr.operator || !isFunction(expr)) return undefined;
@@ -450,6 +463,27 @@ export function differentiate(
450463

451464
const h = DERIVATIVES_TABLE[expr.operator];
452465
if (h === undefined) {
466+
// Try resolving user-defined function calls before falling back to
467+
// symbolic chain rule. Apply the function to wildcards, evaluate to
468+
// get the body, substitute actual arguments, and differentiate.
469+
const opSym = ce.symbol(expr.operator);
470+
if (opSym.operatorDefinition) {
471+
const args = expr.ops;
472+
const wildcards =
473+
args.length === 1
474+
? [ce.symbol('_')]
475+
: args.map((_, i) => ce.symbol(`_${i + 1}`));
476+
const body = ce.function(expr.operator, wildcards).evaluate();
477+
if (body.operator !== expr.operator) {
478+
const subsMap: Record<string, Expression> = {};
479+
wildcards.forEach((w, i) => {
480+
subsMap[sym(w)!] = args[i];
481+
});
482+
const bodyWithArgs = body.subs(subsMap);
483+
return differentiate(bodyWithArgs, v, depth + 1);
484+
}
485+
}
486+
453487
if (expr.nops > 1) return undefined;
454488

455489
// If we don't know how to differentiate this function, assume it's a

test/compute-engine/derivatives.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,46 @@ describe('Multi-argument function derivatives', () => {
541541
});
542542
});
543543

544+
describe('User-defined function derivatives', () => {
545+
// Use a local engine so f(x) := 2x doesn't affect other tests
546+
let ce: InstanceType<typeof import('../../src/compute-engine').ComputeEngine>;
547+
548+
beforeAll(async () => {
549+
const { ComputeEngine } = await import('../../src/compute-engine');
550+
ce = new ComputeEngine();
551+
ce.parse('f(x) := 2x').evaluate();
552+
});
553+
554+
it('f(x) should evaluate to 2x without stack overflow', () => {
555+
const result = ce.parse('f(x)').evaluate();
556+
expect(result.toString()).toMatchInlineSnapshot(`2x`);
557+
});
558+
559+
it('d/dx f(x) where f(x) := 2x should be 2', () => {
560+
const expr = ce.box(['D', ['f', 'x'], 'x']);
561+
const result = expr.evaluate();
562+
expect(result.toString()).toMatchInlineSnapshot(`2`);
563+
});
564+
565+
it('d/dx f as a function symbol where f(x) := 2x should be 2', () => {
566+
// D(Function(Block(f), x)) — Leibniz notation parses f as a function symbol
567+
const expr = ce.parse('\\frac{d}{dx} f');
568+
const result = expr.evaluate();
569+
expect(result.toString()).toMatchInlineSnapshot(`2`);
570+
});
571+
572+
it('d/dx f(x^2) where f(x) := 2x should be 4x', () => {
573+
const expr = ce.box(['D', ['f', ['Square', 'x']], 'x']);
574+
const result = expr.evaluate();
575+
expect(result.toString()).toMatchInlineSnapshot(`4x`);
576+
});
577+
578+
it('f(3) should evaluate to 6', () => {
579+
const result = ce.parse('f(3)').evaluate();
580+
expect(result.toString()).toMatchInlineSnapshot(`6`);
581+
});
582+
});
583+
544584
describe('ND', () => {
545585
it('should compute the numerical approximation of the derivative of a polynomial', () => {
546586
const expr = parse('\\mathrm{ND}(x \\mapsto x^3 + 2x - 4, 2)');

test/compute-engine/latex-syntax/__snapshots__/calculus.test.ts.snap

Lines changed: 28 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ D
7373
`;
7474

7575
exports[`EULER DERIVATIVE NOTATION D^2_x f - second derivative 1`] = `
76-
\\frac{\\mathrm{d}}{\\mathrm{d}x}\\frac{\\mathrm{d}}{\\mathrm{d}x}f
77-
D((x) |-> D((x) |-> f, x), x)
78-
["D", ["Function", ["D", ["Function", "f", "x"], "x"], "x"], "x"]
76+
\\frac{\\mathrm{d}^{2}}{\\mathrm{d}x^{2}}f
77+
D(D((x) |-> f, x), x)
78+
["D", ["D", ["Function", "f", "x"], "x"], "x"]
7979
`;
8080

8181
exports[`EULER DERIVATIVE NOTATION D_t x - different variable 1`] = `
@@ -86,8 +86,8 @@ D((t) |-> x, t)
8686

8787
exports[`EULER DERIVATIVE NOTATION D_x (x^2 + 1) - derivative of expression 1`] = `
8888
\\frac{\\mathrm{d}}{\\mathrm{d}x}x^2+1
89-
D((x) |-> x^2 + 1, x)
90-
["D", ["Function", ["Add", ["Square", "x"], 1], "x"], "x"]
89+
D(x^2 + 1, x)
90+
["D", ["Add", ["Square", "x"], 1], "x"]
9191
`;
9292

9393
exports[`EULER DERIVATIVE NOTATION D_x f - first derivative 1`] = `
@@ -418,8 +418,8 @@ int(sin(x) dx) + 1 === 2
418418

419419
exports[`LAGRANGE PRIME NOTATION WITH ARGUMENTS \\sin'(x) - known function with prime 1`] = `
420420
\\frac{\\mathrm{d}}{\\mathrm{d}x}\\sin(x)
421-
D((x) |-> sin(x), x)
422-
["D", ["Function", ["Sin", "x"], "x"], "x"]
421+
D(sin(x), x)
422+
["D", ["Sin", "x"], "x"]
423423
`;
424424

425425
exports[`LAGRANGE PRIME NOTATION WITH ARGUMENTS f' without arguments - returns Derivative 1`] = `
@@ -429,49 +429,33 @@ Derivative(f)
429429
`;
430430

431431
exports[`LAGRANGE PRIME NOTATION WITH ARGUMENTS f'''(x) - triple prime with argument 1`] = `
432-
\\frac{\\mathrm{d}}{\\mathrm{d}x}\\frac{\\mathrm{d}}{\\mathrm{d}x}\\frac{\\mathrm{d}}{\\mathrm{d}x}f(x)
433-
D((x) |-> D((x) |-> D((x) |-> f(x), x), x), x)
434-
[
435-
"D",
436-
[
437-
"Function",
438-
[
439-
"D",
440-
["Function", ["D", ["Function", ["f", "x"], "x"], "x"], "x"],
441-
"x"
442-
],
443-
"x"
444-
],
445-
"x"
446-
]
432+
\\frac{\\mathrm{d}^{3}}{\\mathrm{d}x^{3}}f(x)
433+
D(D(D(f(x), x), x), x)
434+
["D", ["D", ["D", ["f", "x"], "x"], "x"], "x"]
447435
`;
448436

449437
exports[`LAGRANGE PRIME NOTATION WITH ARGUMENTS f''(x) - double prime with argument 1`] = `
450-
\\frac{\\mathrm{d}}{\\mathrm{d}x}\\frac{\\mathrm{d}}{\\mathrm{d}x}f(x)
451-
D((x) |-> D((x) |-> f(x), x), x)
452-
[
453-
"D",
454-
["Function", ["D", ["Function", ["f", "x"], "x"], "x"], "x"],
455-
"x"
456-
]
438+
\\frac{\\mathrm{d}^{2}}{\\mathrm{d}x^{2}}f(x)
439+
D(D(f(x), x), x)
440+
["D", ["D", ["f", "x"], "x"], "x"]
457441
`;
458442

459443
exports[`LAGRANGE PRIME NOTATION WITH ARGUMENTS f'(x) - single prime with argument 1`] = `
460444
\\frac{\\mathrm{d}}{\\mathrm{d}x}f(x)
461-
D((x) |-> f(x), x)
462-
["D", ["Function", ["f", "x"], "x"], "x"]
445+
D(f(x), x)
446+
["D", ["f", "x"], "x"]
463447
`;
464448

465449
exports[`LAGRANGE PRIME NOTATION WITH ARGUMENTS f'(x, y) - multiple arguments uses first as variable 1`] = `
466450
\\frac{\\mathrm{d}}{\\mathrm{d}x}f(x, y)
467-
D((x) |-> f(x, y), x)
468-
["D", ["Function", ["f", "x", "y"], "x"], "x"]
451+
D(f(x, y), x)
452+
["D", ["f", "x", "y"], "x"]
469453
`;
470454

471455
exports[`LAGRANGE PRIME NOTATION WITH ARGUMENTS g'(t) - different variable 1`] = `
472456
\\frac{\\mathrm{d}}{\\mathrm{d}t}g(t)
473-
D((t) |-> g(t), t)
474-
["D", ["Function", ["g", "t"], "t"], "t"]
457+
D(g(t), t)
458+
["D", ["g", "t"], "t"]
475459
`;
476460

477461
exports[`MULTIPLE INTEGRALS Double integral 1`] = `
@@ -612,49 +596,21 @@ D((t) |-> x, t)
612596
`;
613597

614598
exports[`NEWTON DOT NOTATION Fourth derivative \\ddddot{z} 1`] = `
615-
\\frac{\\mathrm{d}}{\\mathrm{d}t}\\frac{\\mathrm{d}}{\\mathrm{d}t}\\frac{\\mathrm{d}}{\\mathrm{d}t}\\frac{\\mathrm{d}}{\\mathrm{d}t}z
616-
D((t) |-> D((t) |-> D((t) |-> D((t) |-> z, t), t), t), t)
617-
[
618-
"D",
619-
[
620-
"Function",
621-
[
622-
"D",
623-
[
624-
"Function",
625-
[
626-
"D",
627-
["Function", ["D", ["Function", "z", "t"], "t"], "t"],
628-
"t"
629-
],
630-
"t"
631-
],
632-
"t"
633-
],
634-
"t"
635-
],
636-
"t"
637-
]
599+
\\frac{\\mathrm{d}^{4}}{\\mathrm{d}t^{4}}z
600+
D(D(D(D((t) |-> z, t), t), t), t)
601+
["D", ["D", ["D", ["D", ["Function", "z", "t"], "t"], "t"], "t"], "t"]
638602
`;
639603

640604
exports[`NEWTON DOT NOTATION Second derivative \\ddot{x} 1`] = `
641-
\\frac{\\mathrm{d}}{\\mathrm{d}t}\\frac{\\mathrm{d}}{\\mathrm{d}t}x
642-
D((t) |-> D((t) |-> x, t), t)
643-
["D", ["Function", ["D", ["Function", "x", "t"], "t"], "t"], "t"]
605+
\\frac{\\mathrm{d}^{2}}{\\mathrm{d}t^{2}}x
606+
D(D((t) |-> x, t), t)
607+
["D", ["D", ["Function", "x", "t"], "t"], "t"]
644608
`;
645609

646610
exports[`NEWTON DOT NOTATION Third derivative \\dddot{y} 1`] = `
647-
\\frac{\\mathrm{d}}{\\mathrm{d}t}\\frac{\\mathrm{d}}{\\mathrm{d}t}\\frac{\\mathrm{d}}{\\mathrm{d}t}y
648-
D((t) |-> D((t) |-> D((t) |-> y, t), t), t)
649-
[
650-
"D",
651-
[
652-
"Function",
653-
["D", ["Function", ["D", ["Function", "y", "t"], "t"], "t"], "t"],
654-
"t"
655-
],
656-
"t"
657-
]
611+
\\frac{\\mathrm{d}^{3}}{\\mathrm{d}t^{3}}y
612+
D(D(D((t) |-> y, t), t), t)
613+
["D", ["D", ["D", ["Function", "y", "t"], "t"], "t"], "t"]
658614
`;
659615

660616
exports[`REAL WORLD INTEGRALS Integral with non standard typesetting 1`] = `

test/compute-engine/latex-syntax/trigonometry.test.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,13 @@ describe('TRIGONOMETRIC FUNCTIONS inverse, prime', () => {
2626
test(`\\sin^{-1}'(x)`, () =>
2727
expect(check("\\sin^{-1}'(x)")).toMatchInlineSnapshot(`
2828
box = ["D", ["Apply", ["InverseFunction", "Sin"], "x"], "x"]
29-
canonical = ["D", ["Function", ["Arcsin", "x"], "x"], "x"]
29+
canonical = ["D", ["Arcsin", "x"], "x"]
3030
eval-auto = 1 / sqrt(1 - x^2)
3131
`));
3232
test(`\\sin^{-1}''(x)`, () =>
3333
expect(check("\\sin^{-1}''(x)")).toMatchInlineSnapshot(`
3434
box = ["D", ["D", ["Apply", ["InverseFunction", "Sin"], "x"], "x"], "x"]
35-
canonical = [
36-
"D",
37-
["Function", ["D", ["Function", ["Arcsin", "x"], "x"], "x"], "x"],
38-
"x"
39-
]
35+
canonical = ["D", ["D", ["Arcsin", "x"], "x"], "x"]
4036
eval-auto = x / (1 - x^2)^(3/2)
4137
`));
4238
test(`\\cos^{-1\\doubleprime}(x)`, () =>
@@ -48,11 +44,7 @@ describe('TRIGONOMETRIC FUNCTIONS inverse, prime', () => {
4844
test(`\\cos^{-1}\\doubleprime(x)`, () =>
4945
expect(check('\\cos^{-1}\\doubleprime(x)')).toMatchInlineSnapshot(`
5046
box = ["D", ["D", ["Apply", ["InverseFunction", "Cos"], "x"], "x"], "x"]
51-
canonical = [
52-
"D",
53-
["Function", ["D", ["Function", ["Arccos", "x"], "x"], "x"], "x"],
54-
"x"
55-
]
47+
canonical = ["D", ["D", ["Arccos", "x"], "x"], "x"]
5648
eval-auto = -x / (1 - x^2)^(3/2)
5749
`));
5850
});

test/playground.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,16 @@ import { expand } from '../src/compute-engine/boxed-expression/expand';
1212
const ce = new ComputeEngine();
1313
const engine = ce;
1414

15-
console.log(parse('3 \\mathrm{cm} + 5\\mathrm{m}').evaluate().json);
16-
17-
console.log(evaluate('\\frac{123 \\mathrm{J}}{10\\mathrm{m}}').latex);
15+
ce.parse('f(x):=2x').evaluate();
16+
console.log(ce.parse('\\frac{d}{dx}f').json);
17+
console.log(ce.parse('D_x f').json);
18+
console.log(JSON.stringify(ce.parse("f'(x)").json));
19+
// -> ["D",["Function",["Block",["f","x"]],"x"],"x"]
20+
console.log(ce.parse('\\dot{x}').json);
21+
// -> [ 'D', [ 'Function', [ 'Block', 'x' ], 't' ], 't' ]
22+
23+
ce.parse('f(x):=2x').evaluate();
24+
ce.parse('\\frac{d}{dx} f').evaluate().print(); // evaluates to 0
1825

1926
// 1. sin(theta)**2 + cos(theta)**2 → 1 — Clean trig identity, but too simple.
2027
// 2. (alpha**2 - beta**2) / (alpha - beta) → didn't simplify. Engine doesn't cancel the

0 commit comments

Comments
 (0)