@johnhenry/math 0.0.3 → 0.1.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/src/Symbolic.ts CHANGED
@@ -443,15 +443,38 @@ export interface OdeClosedFormResult {
443
443
  implicitRelation?: Expr;
444
444
  }
445
445
 
446
+ /** Options for {@link Symbolic.parse} and {@link Symbolic.fromLatex}. */
447
+ export interface ParseOptions {
448
+ /**
449
+ * Read juxtaposition as multiplication, the way people type maths: `2x`, `x y`, `xy`, `2(x+1)`,
450
+ * `(x+1)(x-1)`, `x(x+1)`, `3sin(x)`. Default `false`, which keeps the strict grammar (`2x` is an
451
+ * error and `xy` is one variable named `xy`).
452
+ *
453
+ * With this on, a run of letters that is not a function call (`name(`) or `pi` is split into
454
+ * single-letter variables, so `xy` is `x*y` and multi-letter variable names such as `theta` are
455
+ * unavailable (use a subscript: `x_1` or `x_a`, which stays one variable). A letter followed by
456
+ * digits is a product too (`x3` is `x*3`). A function name must be followed by `(`. Space
457
+ * between two numbers multiplies (`2 3` is `6`). Juxtaposition has the same precedence as `*`
458
+ * and `/` and binds left to right, so `1/2x` is `(1/2)*x`; `^` binds tighter (`2x^2` is `2*(x^2)`).
459
+ * For `fromLatex` it also lets a function with no brackets take a whole product as its argument
460
+ * (`\sin 2x` is `sin(2*x)`, `\sin x\cos x` is `sin(x)*cos(x)`), and `^` take one token (`x^23` is
461
+ * `x^2*3`, as TeX reads it; `x^{23}` is `x^23`).
462
+ */
463
+ implicitMultiplication?: boolean;
464
+ }
465
+
446
466
  export class Symbolic {
447
- /** Parse an expression string such as `"sin(x^2) + 3*x"` into an AST. */
448
- static parse(input: string): Expr {
449
- return new Parser(input).parse();
467
+ /**
468
+ * Parse an expression string such as `"sin(x^2) + 3*x"` into an AST.
469
+ * Pass `{ implicitMultiplication: true }` to also read `2x`, `x y` and `2(x+1)`; see {@link ParseOptions}.
470
+ */
471
+ static parse(input: string, options: ParseOptions = {}): Expr {
472
+ return new Parser(input, options ?? {}).parse();
450
473
  }
451
474
 
452
475
  static differentiate(expr: Expr | string, variable = "x"): Expr {
453
476
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
454
- return Symbolic.simplify(diff(e, variable));
477
+ return simplifyCore(diff(e, variable));
455
478
  }
456
479
 
457
480
  /**
@@ -465,17 +488,21 @@ export class Symbolic {
465
488
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
466
489
  const steps: DifferentiationStep[] = [];
467
490
  const raw = diffTraced(e, variable, steps);
468
- return { steps, result: Symbolic.simplify(raw) };
491
+ return { steps, result: simplifyCore(raw) };
469
492
  }
470
493
 
494
+ /**
495
+ * Algebraic simplification: constant folding, like-term collection, and, as a last
496
+ * step, expanding the expression as a rational function and cancelling common
497
+ * factors -- `(x+1)^2 - x^2 - 2*x` gives `1` and `(x^2-1)/(x-1)` gives `x+1`.
498
+ * The expanded/cancelled form is used only when it is strictly smaller than the
499
+ * collected form, so a product such as `(x+1)*2` keeps its shape. Non-polynomial
500
+ * subexpressions (`sin(x)`, `x^y`) are treated as opaque symbols. Like any CAS
501
+ * cancellation, `(x^2-1)/(x-1)` is `x+1` as an expression, ignoring that the
502
+ * original is undefined at `x = 1`.
503
+ */
471
504
  static simplify(expr: Expr | string): Expr {
472
- let e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
473
- for (let i = 0; i < 30; i++) {
474
- const next = collectLikeTerms(simplifyOnce(e));
475
- if (equal(next, e)) return next;
476
- e = next;
477
- }
478
- return e;
505
+ return normalizeRational(simplifyCore(expr));
479
506
  }
480
507
 
481
508
  /**
@@ -495,15 +522,15 @@ export class Symbolic {
495
522
  * assumptions (e.g. "x+y is positive" isn't expressible).
496
523
  */
497
524
  static simplifyAssuming(expr: Expr | string, assumptions: Record<string, Assumption>): Expr {
498
- const simplified = Symbolic.simplify(expr);
525
+ const simplified = simplifyCore(expr);
499
526
  const rewritten = applyAssumptions(simplified, assumptions);
500
- return Symbolic.simplify(rewritten);
527
+ return simplifyCore(rewritten);
501
528
  }
502
529
 
503
530
  /** Symbolic anti-derivative (elementary rules; throws {@link NotIntegrableError} otherwise). */
504
531
  static integrate(expr: Expr | string, variable = "x"): Expr {
505
532
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
506
- return Symbolic.simplify(integ(e, variable));
533
+ return simplifyCore(integ(e, variable));
507
534
  }
508
535
 
509
536
  /**
@@ -542,13 +569,13 @@ export class Symbolic {
542
569
  variable = "x",
543
570
  env: Record<string, number> = {},
544
571
  ): number {
545
- const e = Symbolic.simplify(typeof expr === "string" ? Symbolic.parse(expr) : expr);
572
+ const e = simplifyCore(typeof expr === "string" ? Symbolic.parse(expr) : expr);
546
573
  const probe = compileExpr(e);
547
574
  if (hasInteriorSingularity((val: number) => probe({ ...env, [variable]: val }), lower, upper)) {
548
575
  throw new IntegrationSingularityError();
549
576
  }
550
577
  try {
551
- const F = Symbolic.simplify(integ(e, variable));
578
+ const F = simplifyCore(integ(e, variable));
552
579
  const at = (val: number) => evalExpr(F, { ...env, [variable]: val });
553
580
  return at(upper) - at(lower);
554
581
  } catch (err) {
@@ -608,7 +635,7 @@ export class Symbolic {
608
635
  xVar = "x",
609
636
  yVar = "y",
610
637
  ): OdeClosedFormResult {
611
- const e = Symbolic.simplify(typeof dyDxExpr === "string" ? Symbolic.parse(dyDxExpr) : dyDxExpr);
638
+ const e = simplifyCore(typeof dyDxExpr === "string" ? Symbolic.parse(dyDxExpr) : dyDxExpr);
612
639
  const sep = matchSeparable(e, xVar, yVar);
613
640
  if (sep) return solveSeparableOde(sep, xVar, yVar, x0, y0);
614
641
  const lin = matchLinearOde(e, xVar, yVar);
@@ -724,14 +751,14 @@ export class Symbolic {
724
751
  }
725
752
  term = diff(term, variable);
726
753
  }
727
- return Symbolic.simplify(result);
754
+ return simplifyCore(result);
728
755
  }
729
756
 
730
757
  /** Replace every occurrence of a variable with a sub-expression (e.g. for changing variables or plugging in a solved value). */
731
758
  static substitute(expr: Expr | string, variable: string, replacement: Expr | string): Expr {
732
759
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
733
760
  const r = typeof replacement === "string" ? Symbolic.parse(replacement) : replacement;
734
- return Symbolic.simplify(subst(e, variable, r));
761
+ return simplifyCore(subst(e, variable, r));
735
762
  }
736
763
 
737
764
  /** Distribute multiplication over addition/subtraction and expand small integer powers of sums, e.g. `(x+1)^2 -> x^2+2*x+1`. */
@@ -739,7 +766,7 @@ export class Symbolic {
739
766
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
740
767
  let cur = e;
741
768
  for (let i = 0; i < 30; i++) {
742
- const next = Symbolic.simplify(expandOnce(cur));
769
+ const next = simplifyCore(expandOnce(cur));
743
770
  if (equal(next, cur)) return next;
744
771
  cur = next;
745
772
  }
@@ -852,12 +879,16 @@ export class Symbolic {
852
879
  * commands (`\sin`, `\arcsin`, `\sinh`, `\operatorname{sech}`, ...), and the
853
880
  * two-argument `BinaryFuncName` commands (`\min`, `\max`, `\gcd`,
854
881
  * `\operatorname{atan2}`, `\operatorname{hypot}`, `\operatorname{lcm}`).
855
- * Spacing commands (`\,`, `\;`, `\!`, `\quad`) are ignored.
882
+ * Spacing commands (`\,`, `\;`, `\!`, `\quad`) are ignored. Single-token arguments as TeX
883
+ * reads them (`\frac12`, `\frac1x`, `\sqrt2`) and a power on a function command
884
+ * (`\sin^{2}(x)` is `sin(x)^2`) are accepted. An exponent of `-1` on a function command (`\sin^{-1}`) is
885
+ * refused as ambiguous (inverse function or reciprocal): write `\arcsin(x)` or `(\sin x)^{-1}`. For what people type into a math field
886
+ * (`2x`, `\sin 2x`, `(x+1)(x-1)`, `x^23`) pass `{ implicitMultiplication: true }`; see {@link ParseOptions}.
856
887
  *
857
888
  * @throws on LaTeX constructs with no `Expr` equivalent, e.g. `\int` or `\sum`.
858
889
  */
859
- static fromLatex(latex: string): Expr {
860
- return Symbolic.parse(latexToInfix(latex));
890
+ static fromLatex(latex: string, options: ParseOptions = {}): Expr {
891
+ return Symbolic.parse(latexToInfix(latex, options), options);
861
892
  }
862
893
 
863
894
  /**
@@ -878,11 +909,11 @@ export class Symbolic {
878
909
  */
879
910
  static solve(expr: Expr | string, variable = "x"): Expr[] {
880
911
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
881
- const coeffs = polynomialCoeffs(Symbolic.simplify(e), variable, 6);
912
+ const coeffs = polynomialCoeffs(simplifyCore(e), variable, 6);
882
913
  if (!coeffs) {
883
914
  throw new Error(`solve: expression is not a polynomial in "${variable}" of degree ≤ 6.`);
884
915
  }
885
- return solvePolynomial(coeffs).map((r) => Symbolic.simplify(r));
916
+ return solvePolynomial(coeffs).map((r) => simplifyCore(r));
886
917
  }
887
918
 
888
919
  /**
@@ -900,7 +931,7 @@ export class Symbolic {
900
931
  * @throws {SingularSystemError} if the coefficient matrix is singular/rank-deficient.
901
932
  */
902
933
  static solveSystem(equations: (Expr | string)[], variables: string[]): Record<string, number> {
903
- const exprs = equations.map((eq) => Symbolic.simplify(typeof eq === "string" ? Symbolic.parse(eq) : eq));
934
+ const exprs = equations.map((eq) => simplifyCore(typeof eq === "string" ? Symbolic.parse(eq) : eq));
904
935
  if (exprs.length !== variables.length) {
905
936
  throw new Error(
906
937
  `solveSystem: expected ${variables.length} equations for ${variables.length} variables, got ${exprs.length}.`,
@@ -1149,7 +1180,7 @@ export class Symbolic {
1149
1180
  */
1150
1181
  static factor(expr: Expr | string, variable = "x"): Expr {
1151
1182
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
1152
- const simplified = Symbolic.simplify(e);
1183
+ const simplified = simplifyCore(e);
1153
1184
  const coeffs = polynomialCoeffs(simplified, variable, 6);
1154
1185
  if (!coeffs) return simplified;
1155
1186
  return factorPolynomial(coeffs, variable);
@@ -1368,6 +1399,17 @@ function diff(e: Expr, x: string): Expr {
1368
1399
  }
1369
1400
 
1370
1401
  // -- simplification ---------------------------------------------------------
1402
+ /** Constant folding and like-term collection to a fixed point -- no expansion or cancellation. */
1403
+ function simplifyCore(expr: Expr | string): Expr {
1404
+ let e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
1405
+ for (let i = 0; i < 30; i++) {
1406
+ const next = collectLikeTerms(simplifyOnce(e));
1407
+ if (equal(next, e)) return next;
1408
+ e = next;
1409
+ }
1410
+ return e;
1411
+ }
1412
+
1371
1413
  function simplifyOnce(e: Expr): Expr {
1372
1414
  if (e.type === "const" || e.type === "var") return e;
1373
1415
  if (e.type === "neg") {
@@ -2115,7 +2157,7 @@ function freshVariableName(e: Expr, x: string): string {
2115
2157
  * legitimately hit a domain error/NaN on either side).
2116
2158
  */
2117
2159
  function verifyByDifferentiation(candidate: Expr, e: Expr, x: string): boolean {
2118
- const check = Symbolic.simplify(diff(candidate, x));
2160
+ const check = simplifyCore(diff(candidate, x));
2119
2161
  let agreements = 0;
2120
2162
  for (const p of [0.7, 1.3, -0.9]) {
2121
2163
  const expected = evalExpr(e, { [x]: p });
@@ -2140,16 +2182,16 @@ function verifyByDifferentiation(candidate: Expr, e: Expr, x: string): boolean {
2140
2182
  */
2141
2183
  function tryUSubstitution(e: Expr, x: string): Expr | null {
2142
2184
  for (const g of collectSubstitutionCandidates(e, x)) {
2143
- const gPrime = Symbolic.simplify(diff(g, x));
2185
+ const gPrime = simplifyCore(diff(g, x));
2144
2186
  if (isConst(gPrime, 0)) continue;
2145
- const quotient = Symbolic.simplify(divideOutFactors(e, gPrime) ?? div(e, gPrime));
2187
+ const quotient = simplifyCore(divideOutFactors(e, gPrime) ?? div(e, gPrime));
2146
2188
  if (!containsExpr(quotient, g)) continue;
2147
2189
  const uName = freshVariableName(quotient, x);
2148
2190
  const substituted = substExpr(quotient, g, v(uName));
2149
2191
  if (containsVar(substituted, x)) continue;
2150
2192
  try {
2151
2193
  const antiderivativeInU = integRules(substituted, uName);
2152
- const candidate = Symbolic.simplify(substExpr(antiderivativeInU, v(uName), g));
2194
+ const candidate = simplifyCore(substExpr(antiderivativeInU, v(uName), g));
2153
2195
  if (verifyByDifferentiation(candidate, e, x)) return candidate;
2154
2196
  } catch (err) {
2155
2197
  if (!(err instanceof NotIntegrableError)) throw err;
@@ -2270,11 +2312,11 @@ function matchSeparable(e: Expr, xVar: string, yVar: string): { g: Expr; h: Expr
2270
2312
  */
2271
2313
  function matchLinearOde(e: Expr, xVar: string, yVar: string): { p: Expr; q: Expr } | null {
2272
2314
  if (!containsVar(e, yVar)) return null; // handled by matchSeparable's h(y)=1 case instead
2273
- const negP = Symbolic.simplify(diff(e, yVar));
2315
+ const negP = simplifyCore(diff(e, yVar));
2274
2316
  if (containsVar(negP, yVar)) return null; // not affine in y
2275
- const q = Symbolic.simplify(sub(e, mul(negP, v(yVar))));
2317
+ const q = simplifyCore(sub(e, mul(negP, v(yVar))));
2276
2318
  if (containsVar(q, yVar)) return null; // sanity check
2277
- const p = Symbolic.simplify(neg(negP));
2319
+ const p = simplifyCore(neg(negP));
2278
2320
  // Conservative, matching matchSeparable's philosophy: p/q must depend on
2279
2321
  // no free variable other than xVar (an extra symbolic parameter, or a
2280
2322
  // stray reference to yVar that survived simplification, both bail out).
@@ -2337,21 +2379,21 @@ function solveSeparableOde(
2337
2379
  x0: number,
2338
2380
  y0: number,
2339
2381
  ): OdeClosedFormResult {
2340
- const invH = Symbolic.simplify(div(num(1), sep.h));
2382
+ const invH = simplifyCore(div(num(1), sep.h));
2341
2383
  let H: Expr;
2342
2384
  let G: Expr;
2343
2385
  try {
2344
- H = Symbolic.simplify(integ(invH, yVar));
2345
- G = Symbolic.simplify(integ(sep.g, xVar));
2386
+ H = simplifyCore(integ(invH, yVar));
2387
+ G = simplifyCore(integ(sep.g, xVar));
2346
2388
  } catch (err) {
2347
2389
  if (err instanceof NotIntegrableError) throw new NoClosedFormError();
2348
2390
  throw err;
2349
2391
  }
2350
2392
  const C = evalExpr(H, { [yVar]: y0 }) - evalExpr(G, { [xVar]: x0 });
2351
- const rhs = Symbolic.simplify(add(G, num(C)));
2393
+ const rhs = simplifyCore(add(G, num(C)));
2352
2394
  const explicitY = tryInvertForY(H, yVar, rhs, y0);
2353
- if (explicitY) return { explicit: true, y: Symbolic.simplify(explicitY) };
2354
- return { explicit: false, implicitRelation: Symbolic.simplify(sub(H, rhs)) };
2395
+ if (explicitY) return { explicit: true, y: simplifyCore(explicitY) };
2396
+ return { explicit: false, implicitRelation: simplifyCore(sub(H, rhs)) };
2355
2397
  }
2356
2398
 
2357
2399
  function solveLinearOde(
@@ -2365,9 +2407,9 @@ function solveLinearOde(
2365
2407
  let mu: Expr;
2366
2408
  let muQIntegral: Expr;
2367
2409
  try {
2368
- integralP = Symbolic.simplify(integ(lin.p, xVar));
2369
- mu = Symbolic.simplify(fn("exp", integralP));
2370
- muQIntegral = Symbolic.simplify(integ(Symbolic.simplify(mul(mu, lin.q)), xVar));
2410
+ integralP = simplifyCore(integ(lin.p, xVar));
2411
+ mu = simplifyCore(fn("exp", integralP));
2412
+ muQIntegral = simplifyCore(integ(simplifyCore(mul(mu, lin.q)), xVar));
2371
2413
  } catch (err) {
2372
2414
  if (err instanceof NotIntegrableError) throw new NoClosedFormError();
2373
2415
  throw err;
@@ -2376,7 +2418,7 @@ function solveLinearOde(
2376
2418
  const muAtX0 = evalExpr(mu, { [xVar]: x0 });
2377
2419
  const muQIntegralAtX0 = evalExpr(muQIntegral, { [xVar]: x0 });
2378
2420
  const C = y0 * muAtX0 - muQIntegralAtX0;
2379
- const y = Symbolic.simplify(div(add(muQIntegral, num(C)), mu));
2421
+ const y = simplifyCore(div(add(muQIntegral, num(C)), mu));
2380
2422
  return { explicit: true, y };
2381
2423
  }
2382
2424
 
@@ -2429,7 +2471,7 @@ function matchHomogeneous(e: Expr, xVar: string, yVar: string): { vName: string;
2429
2471
  }
2430
2472
  }
2431
2473
  if (attempts === 0 || agreements < 4) return null; // not enough usable probes to trust the match
2432
- const F = Symbolic.simplify(subst(substituted, xVar, num(1)));
2474
+ const F = simplifyCore(subst(substituted, xVar, num(1)));
2433
2475
  return { vName, F };
2434
2476
  }
2435
2477
 
@@ -2461,15 +2503,15 @@ function solveHomogeneousOde(
2461
2503
  ): OdeClosedFormResult {
2462
2504
  const { vName, F } = hom;
2463
2505
  const v0 = y0 / x0;
2464
- const FMinusV = Symbolic.simplify(sub(F, v(vName)));
2506
+ const FMinusV = simplifyCore(sub(F, v(vName)));
2465
2507
  if (FMinusV.type === "const" && FMinusV.value === 0) {
2466
- return { explicit: true, y: Symbolic.simplify(mul(num(v0), v(xVar))) };
2508
+ return { explicit: true, y: simplifyCore(mul(num(v0), v(xVar))) };
2467
2509
  }
2468
2510
  const sepResult = solveSeparableOde({ g: div(num(1), v(xVar)), h: FMinusV }, xVar, vName, x0, v0);
2469
2511
  if (sepResult.explicit && sepResult.y) {
2470
- return { explicit: true, y: Symbolic.simplify(mul(sepResult.y, v(xVar))) };
2512
+ return { explicit: true, y: simplifyCore(mul(sepResult.y, v(xVar))) };
2471
2513
  }
2472
- const implicitRelation = Symbolic.simplify(subst(sepResult.implicitRelation as Expr, vName, div(v(yVar), v(xVar))));
2514
+ const implicitRelation = simplifyCore(subst(sepResult.implicitRelation as Expr, vName, div(v(yVar), v(xVar))));
2473
2515
  return { explicit: false, implicitRelation };
2474
2516
  }
2475
2517
 
@@ -2554,8 +2596,8 @@ function matchBernoulli(e: Expr, xVar: string, yVar: string): { p: Expr; q: Expr
2554
2596
  return null;
2555
2597
  }
2556
2598
  if (powMatch.n === 0) return null; // y^0=1: really just linear, matchLinearOde's job
2557
- const q = powSign === 1 ? powMatch.coef : Symbolic.simplify(neg(powMatch.coef));
2558
- const p = linSign === 1 ? Symbolic.simplify(neg(linMatch.coef)) : linMatch.coef;
2599
+ const q = powSign === 1 ? powMatch.coef : simplifyCore(neg(powMatch.coef));
2600
+ const p = linSign === 1 ? simplifyCore(neg(linMatch.coef)) : linMatch.coef;
2559
2601
  return { p, q, n: powMatch.n };
2560
2602
  }
2561
2603
 
@@ -2582,10 +2624,10 @@ function solveBernoulliOde(
2582
2624
  ): OdeClosedFormResult {
2583
2625
  const oneMinusN = 1 - bern.n;
2584
2626
  const w0 = y0 ** oneMinusN;
2585
- const transformedP = Symbolic.simplify(mul(num(oneMinusN), bern.p));
2586
- const transformedQ = Symbolic.simplify(mul(num(oneMinusN), bern.q));
2627
+ const transformedP = simplifyCore(mul(num(oneMinusN), bern.p));
2628
+ const transformedQ = simplifyCore(mul(num(oneMinusN), bern.q));
2587
2629
  const wResult = solveLinearOde({ p: transformedP, q: transformedQ }, xVar, yVar, x0, w0);
2588
- const y = Symbolic.simplify(pow(wResult.y as Expr, num(1 / oneMinusN)));
2630
+ const y = simplifyCore(pow(wResult.y as Expr, num(1 / oneMinusN)));
2589
2631
  return { explicit: true, y };
2590
2632
  }
2591
2633
 
@@ -2607,7 +2649,7 @@ function matchExact(e: Expr, xVar: string, yVar: string): { M: Expr; N: Expr } |
2607
2649
  const vars = collectFreeVars(e);
2608
2650
  if (![...vars].every((n) => n === xVar || n === yVar)) return null;
2609
2651
  const M = e.left;
2610
- const N = Symbolic.simplify(neg(e.right));
2652
+ const N = simplifyCore(neg(e.right));
2611
2653
  const dMdy = diff(M, yVar);
2612
2654
  const dNdx = diff(N, xVar);
2613
2655
  let agreements = 0;
@@ -2662,8 +2704,8 @@ function solveExactOde(
2662
2704
  let g: Expr;
2663
2705
  let gPrime: Expr;
2664
2706
  try {
2665
- F0 = Symbolic.simplify(integ(M, xVar));
2666
- gPrime = Symbolic.simplify(sub(N, diff(F0, yVar)));
2707
+ F0 = simplifyCore(integ(M, xVar));
2708
+ gPrime = simplifyCore(sub(N, diff(F0, yVar)));
2667
2709
  if (containsVar(gPrime, xVar)) {
2668
2710
  // Symbolic cancellation fell short -- verify x-independence
2669
2711
  // numerically, then pin x to a probe value that evaluates finitely.
@@ -2675,18 +2717,18 @@ function solveExactOde(
2675
2717
  if (Math.abs(atProbe - atOther) > 1e-6 * Math.max(1, Math.abs(atProbe))) {
2676
2718
  throw new NoClosedFormError();
2677
2719
  }
2678
- pinned = Symbolic.simplify(subst(gPrime, xVar, num(xp)));
2720
+ pinned = simplifyCore(subst(gPrime, xVar, num(xp)));
2679
2721
  break;
2680
2722
  }
2681
2723
  if (!pinned) throw new NoClosedFormError();
2682
2724
  gPrime = pinned;
2683
2725
  }
2684
- g = Symbolic.simplify(integ(gPrime, yVar));
2726
+ g = simplifyCore(integ(gPrime, yVar));
2685
2727
  } catch (err) {
2686
2728
  if (err instanceof NotIntegrableError) throw new NoClosedFormError();
2687
2729
  throw err;
2688
2730
  }
2689
- const F = Symbolic.simplify(add(F0, g));
2731
+ const F = simplifyCore(add(F0, g));
2690
2732
  const C = evalExpr(F, { [xVar]: x0, [yVar]: y0 });
2691
2733
  if (!Number.isFinite(C)) throw new NoClosedFormError();
2692
2734
  // Cheap explicit attempt: F = H(y) + G(x) (either order) inverts via the
@@ -2701,12 +2743,12 @@ function solveExactOde(
2701
2743
  ] as const) {
2702
2744
  if (!isPureFunctionOf(yPart, yOnly) || !isPureFunctionOf(xPartSigned, xOnly)) continue;
2703
2745
  if (F.type === "sub" && yPart === b) continue; // F = G(x) - H(y): inverting -H is out of tryInvertForY's shapes
2704
- const rhs = Symbolic.simplify(sub(num(C), xPartSigned));
2746
+ const rhs = simplifyCore(sub(num(C), xPartSigned));
2705
2747
  const explicitY = tryInvertForY(yPart, yVar, rhs, y0);
2706
- if (explicitY) return { explicit: true, y: Symbolic.simplify(explicitY) };
2748
+ if (explicitY) return { explicit: true, y: simplifyCore(explicitY) };
2707
2749
  }
2708
2750
  }
2709
- return { explicit: false, implicitRelation: Symbolic.simplify(sub(F, num(C))) };
2751
+ return { explicit: false, implicitRelation: simplifyCore(sub(F, num(C))) };
2710
2752
  }
2711
2753
 
2712
2754
  /** If `e` is `a·x + b` (a, b constant), return `{ a, b }`, else `null`. */
@@ -3604,12 +3646,47 @@ const CMP_LATEX_CMDS: Record<string, string> = {
3604
3646
  };
3605
3647
 
3606
3648
  /** Convert a LaTeX math string into the plain-infix syntax `Symbolic.parse`'s `Parser` understands. */
3607
- function latexToInfix(latex: string): string {
3608
- const cleaned = latex.replace(/\\left|\\right/g, "").replace(/\\(?:,|;|!|quad|qquad| )/g, "");
3609
- return transformLatex(cleaned);
3649
+ function latexToInfix(latex: string, opts: ParseOptions = {}): string {
3650
+ const cleaned = latex.replace(/\\left|\\right/g, "").replace(/(?<!\\)\\(?:,|;|!|quad|qquad| )/g, "");
3651
+ return transformLatex(cleaned, opts);
3652
+ }
3653
+
3654
+ /**
3655
+ * Where the unbracketed argument of a function command ends under implicit multiplication: the longest
3656
+ * product of numbers, letters, `\\pi`, powers, subscripts and bracket groups starting at `start`
3657
+ * (`\\sin 2x` takes `2x`; `\\sin x\\cos x` takes just `x`). Returns `start` when there is none.
3658
+ */
3659
+ function bareArgumentEnd(s: string, start: number): number {
3660
+ let j = start;
3661
+ const group = (at: number): number => {
3662
+ const open = s[at];
3663
+ return findGroupEnd(s, at, open, open === "{" ? "}" : ")") + 1;
3664
+ };
3665
+ while (j < s.length) {
3666
+ const c = s[j];
3667
+ if (/[A-Za-z0-9.]/.test(c)) {
3668
+ j++;
3669
+ } else if (c === "(" || c === "{") {
3670
+ j = group(j);
3671
+ } else if (c === "_" && s[j + 1] === "{") {
3672
+ j = group(j + 1);
3673
+ } else if (c === "^") {
3674
+ if (s[j + 1] === "{") j = group(j + 1);
3675
+ else if (s[j + 1] === "\\") j += 1 + (/^\\[a-zA-Z]+/.exec(s.slice(j + 1))?.[0].length ?? 1);
3676
+ else j += 2;
3677
+ } else if (/^\\pi(?![a-zA-Z])/.test(s.slice(j))) {
3678
+ j += 3;
3679
+ } else if (c === " " && /^ +(?:[A-Za-z0-9.({]|\\pi(?![a-zA-Z]))/.test(s.slice(j))) {
3680
+ j += s.slice(j).length - s.slice(j).trimStart().length;
3681
+ } else {
3682
+ break;
3683
+ }
3684
+ }
3685
+ return j;
3610
3686
  }
3611
3687
 
3612
- function transformLatex(s: string): string {
3688
+ function transformLatex(s: string, opts: ParseOptions = {}): string {
3689
+ const implicit = opts.implicitMultiplication === true;
3613
3690
  let out = "";
3614
3691
  let i = 0;
3615
3692
 
@@ -3621,11 +3698,20 @@ function transformLatex(s: string): string {
3621
3698
  };
3622
3699
 
3623
3700
  const readGroupOrParenOrToken = (): string => {
3701
+ while (s[i] === " ") i++;
3624
3702
  if (s[i] === "{" || s[i] === "(") {
3625
3703
  const { content, next } = extractGroup(s, i);
3626
3704
  i = next;
3627
3705
  return content;
3628
3706
  }
3707
+ if (implicit) {
3708
+ const end = bareArgumentEnd(s, i);
3709
+ if (end > i) {
3710
+ const run = s.slice(i, end).trim();
3711
+ i = end;
3712
+ return run;
3713
+ }
3714
+ }
3629
3715
  const m = /^[a-zA-Z_]\w*|^\d+\.?\d*(?:[eE][+-]?\d+)?/.exec(s.slice(i));
3630
3716
  if (m) {
3631
3717
  i += m[0].length;
@@ -3634,6 +3720,32 @@ function transformLatex(s: string): string {
3634
3720
  throw new Error(`Expected an argument at position ${i} in LaTeX source: ${s}`);
3635
3721
  };
3636
3722
 
3723
+ /** A `{...}` group, or the one token TeX takes without braces: a digit, a letter or a `\\command`. */
3724
+ const readGroupOrToken = (): string => {
3725
+ while (s[i] === " ") i++;
3726
+ if (s[i] === "{") return readGroup();
3727
+ const m = /^(?:\\[a-zA-Z]+|[a-zA-Z0-9])/.exec(s.slice(i));
3728
+ if (!m) throw new Error(`Expected an argument at position ${i} in LaTeX source: ${s}`);
3729
+ i += m[0].length;
3730
+ return m[0];
3731
+ };
3732
+
3733
+ /** A power written on a function command (`\\sin^{2}`, `\\sin^2`), or null. */
3734
+ const readFunctionPower = (): string | null => {
3735
+ if (s[i] !== "^") return null;
3736
+ i++;
3737
+ const power = readGroupOrToken();
3738
+ // `\sin^{-1}` is arcsin in most textbooks and calculators but 1/sin in strict TeX/notation; guessing
3739
+ // wrong would silently change the maths, so it is refused rather than interpreted.
3740
+ if (/^\s*-\s*1\s*$/.test(power)) {
3741
+ throw new Error(
3742
+ "Ambiguous LaTeX: a function with exponent -1 (e.g. \\sin^{-1}) may mean the inverse function or the reciprocal; " +
3743
+ "write \\arcsin(x) for the inverse, or (\\sin x)^{-1} / 1/\\sin x for the reciprocal",
3744
+ );
3745
+ }
3746
+ return power;
3747
+ };
3748
+
3637
3749
  /** Read a `(...)`/`{...}` group holding exactly 2 comma-separated arguments, for `call2` functions. */
3638
3750
  const readArgPair = (): [string, string] => {
3639
3751
  if (s[i] !== "(" && s[i] !== "{") throw new Error(`Expected '(' at position ${i} in LaTeX source: ${s}`);
@@ -3683,9 +3795,10 @@ function transformLatex(s: string): string {
3683
3795
  const [exprPart, condPart] = cols;
3684
3796
  if (condPart.trim() === "\\text{otherwise}") {
3685
3797
  if (otherwise !== null) throw new Error("\\begin{cases} may have only one \\text{otherwise} row");
3686
- otherwise = transformLatex(exprPart);
3798
+ otherwise = transformLatex(exprPart, opts);
3687
3799
  } else {
3688
- parts.push(`${transformLatex(condPart)}, ${transformLatex(exprPart)}`);
3800
+ const condition = condPart.replace(/^\s*\\text\{\s*(?:if|when)\s*\}/, ""); // "x & \text{if } x<0" as MathLive writes it
3801
+ parts.push(`${transformLatex(condition, opts)}, ${transformLatex(exprPart, opts)}`);
3689
3802
  }
3690
3803
  }
3691
3804
  if (otherwise === null) {
@@ -3694,28 +3807,28 @@ function transformLatex(s: string): string {
3694
3807
  out += `piecewise(${parts.concat(otherwise).join(", ")})`;
3695
3808
  continue;
3696
3809
  }
3697
- if (cmd === "\\frac") {
3698
- const numerator = readGroup();
3699
- const denominator = readGroup();
3700
- out += `((${transformLatex(numerator)})/(${transformLatex(denominator)}))`;
3810
+ if (cmd === "\\frac" || cmd === "\\dfrac" || cmd === "\\tfrac") {
3811
+ const numerator = readGroupOrToken();
3812
+ const denominator = readGroupOrToken();
3813
+ out += `((${transformLatex(numerator, opts)})/(${transformLatex(denominator, opts)}))`;
3701
3814
  continue;
3702
3815
  }
3703
3816
  if (cmd === "\\sqrt") {
3704
3817
  if (s[i] === "[") {
3705
3818
  const { content: root, next } = extractGroup(s, i);
3706
3819
  i = next;
3707
- const arg = readGroup();
3708
- const transformedRoot = transformLatex(root).trim();
3820
+ const arg = readGroupOrToken();
3821
+ const transformedRoot = transformLatex(root, opts).trim();
3709
3822
  // \sqrt[3]{...} maps to cbrt(...) rather than a fractional power so
3710
3823
  // negative arguments evaluate correctly (Math.cbrt(-8) = -2, but
3711
3824
  // (-8)^(1/3) is NaN under JS's `**`).
3712
3825
  out +=
3713
3826
  transformedRoot === "3"
3714
- ? `cbrt(${transformLatex(arg)})`
3715
- : `((${transformLatex(arg)})^(1/(${transformedRoot})))`;
3827
+ ? `cbrt(${transformLatex(arg, opts)})`
3828
+ : `((${transformLatex(arg, opts)})^(1/(${transformedRoot})))`;
3716
3829
  } else {
3717
- const arg = readGroup();
3718
- out += `sqrt(${transformLatex(arg)})`;
3830
+ const arg = readGroupOrToken();
3831
+ out += `sqrt(${transformLatex(arg, opts)})`;
3719
3832
  }
3720
3833
  continue;
3721
3834
  }
@@ -3729,8 +3842,10 @@ function transformLatex(s: string): string {
3729
3842
  if (base !== "10" && base !== "2") {
3730
3843
  throw new Error(`Unsupported LaTeX construct: \\log base ${base} (only base 10 and base 2 are supported)`);
3731
3844
  }
3845
+ const power = readFunctionPower();
3732
3846
  const arg = readGroupOrParenOrToken();
3733
- out += `${base === "2" ? "log2" : "log10"}(${transformLatex(arg)})`;
3847
+ const call = `${base === "2" ? "log2" : "log10"}(${transformLatex(arg, opts)})`;
3848
+ out += power === null ? call : `${call}^(${transformLatex(power, opts)})`;
3734
3849
  continue;
3735
3850
  }
3736
3851
  if (cmd === "\\lfloor" || cmd === "\\lceil") {
@@ -3739,7 +3854,7 @@ function transformLatex(s: string): string {
3739
3854
  if (closeIdx === -1) throw new Error(`Unmatched '${cmd}' in LaTeX source`);
3740
3855
  const inner = s.slice(i, closeIdx);
3741
3856
  i = closeIdx + closeCmd.length;
3742
- out += `${cmd === "\\lfloor" ? "floor" : "ceil"}(${transformLatex(inner)})`;
3857
+ out += `${cmd === "\\lfloor" ? "floor" : "ceil"}(${transformLatex(inner, opts)})`;
3743
3858
  continue;
3744
3859
  }
3745
3860
  if (cmd === "\\operatorname") {
@@ -3747,25 +3862,29 @@ function transformLatex(s: string): string {
3747
3862
  const binaryName = OPERATORNAME_BINARY_FUNC_NAMES[opName];
3748
3863
  if (binaryName) {
3749
3864
  const [left, right] = readArgPair();
3750
- out += `${binaryName}(${transformLatex(left)}, ${transformLatex(right)})`;
3865
+ out += `${binaryName}(${transformLatex(left, opts)}, ${transformLatex(right, opts)})`;
3751
3866
  continue;
3752
3867
  }
3753
3868
  const funcName = OPERATORNAME_FUNC_NAMES[opName];
3754
3869
  if (!funcName) throw new Error(`Unsupported LaTeX construct: \\operatorname{${opName}}`);
3870
+ const power = readFunctionPower();
3755
3871
  const arg = readGroupOrParenOrToken();
3756
- out += `${funcName}(${transformLatex(arg)})`;
3872
+ const call = `${funcName}(${transformLatex(arg, opts)})`;
3873
+ out += power === null ? call : `${call}^(${transformLatex(power, opts)})`;
3757
3874
  continue;
3758
3875
  }
3759
3876
  const binaryFuncName = LATEX_BINARY_FUNC_NAMES[cmd];
3760
3877
  if (binaryFuncName) {
3761
3878
  const [left, right] = readArgPair();
3762
- out += `${binaryFuncName}(${transformLatex(left)}, ${transformLatex(right)})`;
3879
+ out += `${binaryFuncName}(${transformLatex(left, opts)}, ${transformLatex(right, opts)})`;
3763
3880
  continue;
3764
3881
  }
3765
3882
  const funcName = LATEX_FUNC_NAMES[cmd];
3766
3883
  if (funcName) {
3884
+ const power = readFunctionPower();
3767
3885
  const arg = readGroupOrParenOrToken();
3768
- out += `${funcName}(${transformLatex(arg)})`;
3886
+ const call = `${funcName}(${transformLatex(arg, opts)})`;
3887
+ out += power === null ? call : `${call}^(${transformLatex(power, opts)})`;
3769
3888
  continue;
3770
3889
  }
3771
3890
  throw new Error(`Unsupported LaTeX construct: ${cmd}`);
@@ -3775,13 +3894,20 @@ function transformLatex(s: string): string {
3775
3894
  if (closeIdx === -1) throw new Error("Unmatched '|' in LaTeX source");
3776
3895
  const inner = s.slice(i + 1, closeIdx);
3777
3896
  i = closeIdx + 1;
3778
- out += `abs(${transformLatex(inner)})`;
3897
+ out += `abs(${transformLatex(inner, opts)})`;
3898
+ continue;
3899
+ }
3900
+ if (implicit && ch === "^" && /^[a-zA-Z0-9]|^\\[a-zA-Z]/.test(s.slice(i + 1))) {
3901
+ // TeX reads `x^23` as `x^2` times `3`: the exponent is one token
3902
+ const m = /^(?:\\[a-zA-Z]+|[a-zA-Z0-9])/.exec(s.slice(i + 1)) as RegExpExecArray;
3903
+ i += 1 + m[0].length;
3904
+ out += `^(${transformLatex(m[0], opts)})`;
3779
3905
  continue;
3780
3906
  }
3781
3907
  if (ch === "^" && s[i + 1] === "{") {
3782
3908
  const { content, next } = extractGroup(s, i + 1);
3783
3909
  i = next;
3784
- out += `^(${transformLatex(content)})`;
3910
+ out += `^(${transformLatex(content, opts)})`;
3785
3911
  continue;
3786
3912
  }
3787
3913
  if (ch === "_" && s[i + 1] === "{") {
@@ -3793,7 +3919,7 @@ function transformLatex(s: string): string {
3793
3919
  if (ch === "{") {
3794
3920
  const { content, next } = extractGroup(s, i);
3795
3921
  i = next;
3796
- out += `(${transformLatex(content)})`;
3922
+ out += `(${transformLatex(content, opts)})`;
3797
3923
  continue;
3798
3924
  }
3799
3925
  if (ch === "=") {
@@ -3810,6 +3936,398 @@ function transformLatex(s: string): string {
3810
3936
  return out;
3811
3937
  }
3812
3938
 
3939
+ // -- rational-function normalization (expand + cancel) ----------------------------
3940
+ // `simplify` finishes by trying to read the expression as a quotient of two
3941
+ // polynomials (over the rationals, in "atoms": variables and any subexpression that
3942
+ // is not itself polynomial, such as `sin(x)`), expanding both, cancelling their
3943
+ // greatest common divisor, and printing the result back out. The result is used only
3944
+ // when it is strictly smaller than what `simplify` had already reached, so products
3945
+ // like `(x+1)*2` that are no smaller expanded keep their form.
3946
+
3947
+ /** A monomial's exponent per atom, and its coefficient. */
3948
+ interface PolyTerm {
3949
+ exps: number[];
3950
+ c: Rational;
3951
+ }
3952
+ type Poly = PolyTerm[]; // sorted by lex order descending, no zero coefficients, no duplicate monomials
3953
+
3954
+ const MAX_POLY_TERMS = 400;
3955
+ const MAX_RATIONAL_POWER = 8;
3956
+
3957
+ class RatBail extends Error {}
3958
+
3959
+ interface RatCtx {
3960
+ atoms: Expr[];
3961
+ keys: string[];
3962
+ }
3963
+
3964
+ function atomIndex(ctx: RatCtx, e: Expr): number {
3965
+ const key = render(e, 0);
3966
+ let i = ctx.keys.indexOf(key);
3967
+ if (i < 0) {
3968
+ i = ctx.keys.length;
3969
+ ctx.keys.push(key);
3970
+ ctx.atoms.push(e);
3971
+ }
3972
+ return i;
3973
+ }
3974
+
3975
+ function expsCmp(a: number[], b: number[]): number {
3976
+ const n = Math.max(a.length, b.length);
3977
+ for (let i = 0; i < n; i++) {
3978
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
3979
+ if (d !== 0) return d;
3980
+ }
3981
+ return 0;
3982
+ }
3983
+
3984
+ function expsKey(a: number[]): string {
3985
+ let end = a.length;
3986
+ while (end > 0 && !a[end - 1]) end--;
3987
+ return a.slice(0, end).join(",");
3988
+ }
3989
+
3990
+ function polyNormalize(terms: PolyTerm[]): Poly {
3991
+ const byKey = new Map<string, PolyTerm>();
3992
+ for (const t of terms) {
3993
+ const k = expsKey(t.exps);
3994
+ const prev = byKey.get(k);
3995
+ if (prev) byKey.set(k, { exps: prev.exps, c: prev.c.add(t.c) });
3996
+ else byKey.set(k, { exps: t.exps, c: t.c });
3997
+ }
3998
+ const out = [...byKey.values()].filter((t) => !t.c.isZero());
3999
+ out.sort((a, b) => expsCmp(b.exps, a.exps));
4000
+ if (out.length > MAX_POLY_TERMS) throw new RatBail();
4001
+ return out;
4002
+ }
4003
+
4004
+ const polyConst = (c: Rational): Poly => (c.isZero() ? [] : [{ exps: [], c }]);
4005
+ const polyAdd = (a: Poly, b: Poly): Poly => polyNormalize([...a, ...b]);
4006
+ const polyNeg = (a: Poly): Poly => a.map((t) => ({ exps: t.exps, c: t.c.negate() }));
4007
+ const polySub = (a: Poly, b: Poly): Poly => polyAdd(a, polyNeg(b));
4008
+
4009
+ function polyMul(a: Poly, b: Poly): Poly {
4010
+ if (a.length * b.length > MAX_POLY_TERMS * 20) throw new RatBail();
4011
+ const out: PolyTerm[] = [];
4012
+ for (const x of a) {
4013
+ for (const y of b) {
4014
+ const n = Math.max(x.exps.length, y.exps.length);
4015
+ const exps: number[] = [];
4016
+ for (let i = 0; i < n; i++) exps.push((x.exps[i] ?? 0) + (y.exps[i] ?? 0));
4017
+ out.push({ exps, c: x.c.multiply(y.c) });
4018
+ }
4019
+ }
4020
+ return polyNormalize(out);
4021
+ }
4022
+
4023
+ function polyEqual(a: Poly, b: Poly): boolean {
4024
+ return a.length === b.length && a.every((t, i) => expsKey(t.exps) === expsKey(b[i].exps) && t.c.equals(b[i].c));
4025
+ }
4026
+
4027
+ const polyIsConst = (a: Poly): boolean => a.length === 0 || (a.length === 1 && expsKey(a[0].exps) === "");
4028
+
4029
+ /** Division in lex order: `a = q*b + r` (`b` must be nonzero). */
4030
+ function polyDivMod(a: Poly, b: Poly): { q: Poly; r: Poly } {
4031
+ const lead = b[0];
4032
+ let rem = a;
4033
+ const q: PolyTerm[] = [];
4034
+ const r: PolyTerm[] = [];
4035
+ let guard = 0;
4036
+ while (rem.length > 0) {
4037
+ if (++guard > 20_000) throw new RatBail();
4038
+ const t = rem[0];
4039
+ const n = Math.max(t.exps.length, lead.exps.length);
4040
+ const exps: number[] = [];
4041
+ let divisible = true;
4042
+ for (let i = 0; i < n; i++) {
4043
+ const d = (t.exps[i] ?? 0) - (lead.exps[i] ?? 0);
4044
+ if (d < 0) divisible = false;
4045
+ exps.push(d);
4046
+ }
4047
+ if (!divisible) {
4048
+ r.push(t);
4049
+ rem = rem.slice(1);
4050
+ continue;
4051
+ }
4052
+ const qt: PolyTerm = { exps, c: t.c.divide(lead.c) };
4053
+ q.push(qt);
4054
+ rem = polySub(rem, polyMul([qt], b));
4055
+ }
4056
+ return { q: polyNormalize(q), r: polyNormalize(r) };
4057
+ }
4058
+
4059
+ function polyAtoms(...ps: Poly[]): Set<number> {
4060
+ const s = new Set<number>();
4061
+ for (const p of ps) {
4062
+ for (const t of p) {
4063
+ for (let i = 0; i < t.exps.length; i++) if (t.exps[i] > 0) s.add(i);
4064
+ }
4065
+ }
4066
+ return s;
4067
+ }
4068
+
4069
+ /** Monic GCD of two polynomials in one atom (Euclid); 1 when several atoms are involved. */
4070
+ function polyGcd(a: Poly, b: Poly): Poly {
4071
+ if (polyAtoms(a, b).size > 1) return polyConst(Rational.One);
4072
+ let x = a;
4073
+ let y = b;
4074
+ while (y.length > 0) {
4075
+ const { r } = polyDivMod(x, y);
4076
+ x = y;
4077
+ y = r;
4078
+ }
4079
+ if (x.length === 0) return polyConst(Rational.One);
4080
+ const lc = x[0].c;
4081
+ return x.map((t) => ({ exps: t.exps, c: t.c.divide(lc) }));
4082
+ }
4083
+
4084
+ /** The largest monomial dividing every term (exponent-wise minimum). */
4085
+ function monomialContent(p: Poly): number[] {
4086
+ if (p.length === 0) return [];
4087
+ const n = Math.max(...p.map((t) => t.exps.length));
4088
+ const out: number[] = [];
4089
+ for (let i = 0; i < n; i++) out.push(Math.min(...p.map((t) => t.exps[i] ?? 0)));
4090
+ return out;
4091
+ }
4092
+
4093
+ function polyDivMonomial(p: Poly, m: number[]): Poly {
4094
+ return p.map((t) => ({ exps: t.exps.map((e, i) => e - (m[i] ?? 0)), c: t.c }));
4095
+ }
4096
+
4097
+ interface Ratio {
4098
+ n: Poly;
4099
+ d: Poly;
4100
+ }
4101
+
4102
+ const ratioFromPoly = (n: Poly): Ratio => ({ n, d: polyConst(Rational.One) });
4103
+
4104
+ function ratioAdd(a: Ratio, b: Ratio, subtract: boolean): Ratio {
4105
+ const bn = subtract ? polyNeg(b.n) : b.n;
4106
+ if (polyEqual(a.d, b.d)) return { n: polyAdd(a.n, bn), d: a.d };
4107
+ return { n: polyAdd(polyMul(a.n, b.d), polyMul(bn, a.d)), d: polyMul(a.d, b.d) };
4108
+ }
4109
+
4110
+ const ratioMul = (a: Ratio, b: Ratio): Ratio => ({ n: polyMul(a.n, b.n), d: polyMul(a.d, b.d) });
4111
+
4112
+ function ratioDiv(a: Ratio, b: Ratio): Ratio {
4113
+ if (b.n.length === 0) throw new RatBail(); // division by zero: leave it to evaluation
4114
+ return { n: polyMul(a.n, b.d), d: polyMul(a.d, b.n) };
4115
+ }
4116
+
4117
+ function ratioPow(a: Ratio, n: number): Ratio {
4118
+ const k = Math.abs(n);
4119
+ if (k > 1000) throw new RatBail();
4120
+ const singleTerm = a.n.length === 1 && a.d.length === 1;
4121
+ if (!singleTerm && k > MAX_RATIONAL_POWER) throw new RatBail();
4122
+ let result: Ratio;
4123
+ if (singleTerm) {
4124
+ const mono = (t: PolyTerm): PolyTerm => ({ exps: t.exps.map((e) => e * k), c: t.c.pow(k) });
4125
+ result = { n: [mono(a.n[0])], d: [mono(a.d[0])] };
4126
+ } else {
4127
+ result = ratioFromPoly(polyConst(Rational.One));
4128
+ for (let i = 0; i < k; i++) result = ratioMul(result, a);
4129
+ }
4130
+ return n < 0 ? ratioDiv(ratioFromPoly(polyConst(Rational.One)), result) : result;
4131
+ }
4132
+
4133
+ function exactRational(value: number): Rational | null {
4134
+ if (!Number.isFinite(value)) return null;
4135
+ if (Number.isInteger(value)) return new Rational(BigInt(value));
4136
+ const r = Rational.fromNumber(value, 10_000);
4137
+ return r.toNumber() === value ? r : null;
4138
+ }
4139
+
4140
+ function toRatio(e: Expr, ctx: RatCtx): Ratio {
4141
+ const atom = (x: Expr): Ratio => {
4142
+ const i = atomIndex(ctx, x);
4143
+ const exps: number[] = new Array(i + 1).fill(0);
4144
+ exps[i] = 1;
4145
+ return ratioFromPoly([{ exps, c: Rational.One }]);
4146
+ };
4147
+ switch (e.type) {
4148
+ case "const": {
4149
+ const r = exactRational(e.value);
4150
+ return r ? ratioFromPoly(polyConst(r)) : atom(e);
4151
+ }
4152
+ case "var":
4153
+ return atom(e);
4154
+ case "neg": {
4155
+ const a = toRatio(e.arg, ctx);
4156
+ return { n: polyNeg(a.n), d: a.d };
4157
+ }
4158
+ case "add":
4159
+ return ratioAdd(toRatio(e.left, ctx), toRatio(e.right, ctx), false);
4160
+ case "sub":
4161
+ return ratioAdd(toRatio(e.left, ctx), toRatio(e.right, ctx), true);
4162
+ case "mul":
4163
+ return ratioMul(toRatio(e.left, ctx), toRatio(e.right, ctx));
4164
+ case "div":
4165
+ return ratioDiv(toRatio(e.left, ctx), toRatio(e.right, ctx));
4166
+ case "pow": {
4167
+ if (e.exp.type === "const" && Number.isInteger(e.exp.value)) {
4168
+ return ratioPow(toRatio(e.base, ctx), e.exp.value);
4169
+ }
4170
+ return atom(pow(normalizeRational(e.base), normalizeRational(e.exp)));
4171
+ }
4172
+ case "func":
4173
+ return atom(fn(e.name, normalizeRational(e.arg)));
4174
+ case "call2":
4175
+ return atom(call2(e.name, normalizeRational(e.left), normalizeRational(e.right)));
4176
+ case "cmp":
4177
+ return atom(cmp(e.op, normalizeRational(e.left), normalizeRational(e.right)));
4178
+ case "piecewise":
4179
+ return atom(
4180
+ piecewise(
4181
+ e.branches.map((br) => ({ cond: normalizeRational(br.cond), expr: normalizeRational(br.expr) })),
4182
+ normalizeRational(e.otherwise),
4183
+ ),
4184
+ );
4185
+ case "sum":
4186
+ return atom(sumExpr(e.variable, normalizeRational(e.from), normalizeRational(e.to), normalizeRational(e.body)));
4187
+ case "product":
4188
+ return atom(
4189
+ productExpr(e.variable, normalizeRational(e.from), normalizeRational(e.to), normalizeRational(e.body)),
4190
+ );
4191
+ }
4192
+ }
4193
+
4194
+ function bigGcd(a: bigint, b: bigint): bigint {
4195
+ let x = a < 0n ? -a : a;
4196
+ let y = b < 0n ? -b : b;
4197
+ while (y) [x, y] = [y, x % y];
4198
+ return x;
4199
+ }
4200
+
4201
+ /** Cancel common factors, then scale to coprime integer coefficients with a positive denominator lead. */
4202
+ function ratioReduce(r: Ratio): Ratio {
4203
+ if (r.n.length === 0) return ratioFromPoly([]);
4204
+ let { n, d } = r;
4205
+ const m = monomialContent([...n, ...d]);
4206
+ if (m.some((e) => e > 0)) {
4207
+ n = polyDivMonomial(n, m);
4208
+ d = polyDivMonomial(d, m);
4209
+ }
4210
+ if (!polyIsConst(d)) {
4211
+ const g = polyGcd(n, d);
4212
+ if (!polyIsConst(g)) {
4213
+ n = polyDivMod(n, g).q;
4214
+ d = polyDivMod(d, g).q;
4215
+ } else {
4216
+ // Several atoms: still cancel when one side divides the other exactly.
4217
+ const dn = polyDivMod(n, d);
4218
+ if (dn.r.length === 0) {
4219
+ n = dn.q;
4220
+ d = polyConst(Rational.One);
4221
+ } else {
4222
+ const nd = polyDivMod(d, n);
4223
+ if (nd.r.length === 0) {
4224
+ d = nd.q;
4225
+ n = polyConst(Rational.One);
4226
+ }
4227
+ }
4228
+ }
4229
+ }
4230
+ if (polyIsConst(d)) {
4231
+ const c = d[0].c;
4232
+ return { n: n.map((t) => ({ exps: t.exps, c: t.c.divide(c) })), d: polyConst(Rational.One) };
4233
+ }
4234
+ // integer coefficients: multiply through by the lcm of the denominators, divide by the content
4235
+ let lcm = 1n;
4236
+ for (const t of [...n, ...d]) {
4237
+ const den = t.c.denominator;
4238
+ lcm = (lcm / bigGcd(lcm, den)) * den;
4239
+ }
4240
+ const scale = new Rational(lcm);
4241
+ n = n.map((t) => ({ exps: t.exps, c: t.c.multiply(scale) }));
4242
+ d = d.map((t) => ({ exps: t.exps, c: t.c.multiply(scale) }));
4243
+ let content = 0n;
4244
+ for (const t of [...n, ...d]) content = bigGcd(content, t.c.numerator);
4245
+ if (content > 1n) {
4246
+ const inv = new Rational(1n, content);
4247
+ n = n.map((t) => ({ exps: t.exps, c: t.c.multiply(inv) }));
4248
+ d = d.map((t) => ({ exps: t.exps, c: t.c.multiply(inv) }));
4249
+ }
4250
+ if (d[0].c.sign < 0) {
4251
+ n = polyNeg(n);
4252
+ d = polyNeg(d);
4253
+ }
4254
+ return { n, d };
4255
+ }
4256
+
4257
+ function monomialExpr(exps: number[], ctx: RatCtx): Expr | null {
4258
+ let out: Expr | null = null;
4259
+ for (let i = 0; i < exps.length; i++) {
4260
+ const e = exps[i] ?? 0;
4261
+ if (e === 0) continue;
4262
+ const factor = e === 1 ? ctx.atoms[i] : pow(ctx.atoms[i], num(e));
4263
+ out = out ? mul(out, factor) : factor;
4264
+ }
4265
+ return out;
4266
+ }
4267
+
4268
+ /** A polynomial as a descending-degree sum; fractional coefficients print as `p*m/q`. */
4269
+ function polyToTree(p: Poly, ctx: RatCtx): Expr {
4270
+ if (p.length === 0) return num(0);
4271
+ const ordered = [...p].sort((a, b) => {
4272
+ const da = a.exps.reduce((s, x) => s + x, 0);
4273
+ const db = b.exps.reduce((s, x) => s + x, 0);
4274
+ return db - da || expsCmp(b.exps, a.exps);
4275
+ });
4276
+ let out: Expr | null = null;
4277
+ for (const t of ordered) {
4278
+ const negative = t.c.sign < 0;
4279
+ const abs = t.c.abs();
4280
+ const mono = monomialExpr(t.exps, ctx);
4281
+ const numer = abs.numerator;
4282
+ let body: Expr;
4283
+ if (!mono) body = num(abs.toNumber());
4284
+ else if (numer === 1n && abs.denominator === 1n) body = mono;
4285
+ else if (abs.denominator === 1n) body = mul(num(Number(numer)), mono);
4286
+ else body = div(numer === 1n ? mono : mul(num(Number(numer)), mono), num(Number(abs.denominator)));
4287
+ if (!out) out = negative ? neg(body) : body;
4288
+ else out = negative ? sub(out, body) : add(out, body);
4289
+ }
4290
+ return out as Expr;
4291
+ }
4292
+
4293
+ function nodeCount(e: Expr): number {
4294
+ switch (e.type) {
4295
+ case "const":
4296
+ case "var":
4297
+ return 1;
4298
+ case "neg":
4299
+ case "func":
4300
+ return 1 + nodeCount(e.arg);
4301
+ case "pow":
4302
+ return 1 + nodeCount(e.base) + nodeCount(e.exp);
4303
+ case "piecewise":
4304
+ return 1 + nodeCount(e.otherwise) + e.branches.reduce((s, br) => s + nodeCount(br.cond) + nodeCount(br.expr), 0);
4305
+ case "sum":
4306
+ case "product":
4307
+ return 1 + nodeCount(e.from) + nodeCount(e.to) + nodeCount(e.body);
4308
+ default:
4309
+ return 1 + nodeCount(e.left) + nodeCount(e.right);
4310
+ }
4311
+ }
4312
+
4313
+ /**
4314
+ * Expand `e` as a rational function and cancel common factors, returning the result only
4315
+ * when it has strictly fewer nodes than `e`; otherwise returns `e` itself.
4316
+ */
4317
+ function normalizeRational(e: Expr): Expr {
4318
+ if (e.type === "const" || e.type === "var") return e;
4319
+ try {
4320
+ const ctx: RatCtx = { atoms: [], keys: [] };
4321
+ const reduced = ratioReduce(toRatio(e, ctx));
4322
+ const numer = polyToTree(reduced.n, ctx);
4323
+ const candidate = polyIsConst(reduced.d) ? numer : div(numer, polyToTree(reduced.d, ctx));
4324
+ return nodeCount(candidate) < nodeCount(e) ? candidate : e;
4325
+ } catch (err) {
4326
+ if (err instanceof RatBail) return e;
4327
+ throw err;
4328
+ }
4329
+ }
4330
+
3813
4331
  // -- equation solving & factoring ----------------------------------------------
3814
4332
  /** Best-effort exact `Expr` for a numeric value: an integer, a reduced fraction, or (as a last resort) the raw float. */
3815
4333
  function toExactExpr(value: number): Expr {
@@ -3841,7 +4359,7 @@ function linearCoeffsForSystem(eq: Expr, variables: string[]): number[] | null {
3841
4359
  if (!Number.isFinite(constant)) return null;
3842
4360
  const coeffs: number[] = [];
3843
4361
  for (const name of variables) {
3844
- const derivative = Symbolic.simplify(diff(eq, name));
4362
+ const derivative = simplifyCore(diff(eq, name));
3845
4363
  if (variables.some((other) => containsVar(derivative, other))) return null;
3846
4364
  const coeff = evalExpr(derivative, zeroEnv);
3847
4365
  if (!Number.isFinite(coeff)) return null;
@@ -3870,7 +4388,7 @@ function polynomialCoeffs(expr: Expr, variable: string, maxDegree: number): numb
3870
4388
  // derivative of x^0 (`0·x^-1`) evaluates to `0·Infinity = NaN` at x=0
3871
4389
  // even though it is symbolically zero — simplifying collapses `0·anything`
3872
4390
  // to `0` before that indeterminate form can arise.
3873
- term = Symbolic.simplify(diff(term, variable));
4391
+ term = simplifyCore(diff(term, variable));
3874
4392
  }
3875
4393
  while (coeffs.length > 1 && Math.abs(coeffs[coeffs.length - 1]) < 1e-9) coeffs.pop();
3876
4394
  for (const p of [1.3, -2.1, 3.7]) {
@@ -4058,7 +4576,7 @@ function factorPolynomial(coeffsIn: number[], variable: string): Expr {
4058
4576
  } else if (Math.abs(remaining[0] - 1) > 1e-9) {
4059
4577
  result = mul(result, toExactExpr(remaining[0]));
4060
4578
  }
4061
- return Symbolic.simplify(result);
4579
+ return simplifyCore(result);
4062
4580
  }
4063
4581
 
4064
4582
  // -- limits ---------------------------------------------------------------------
@@ -4083,8 +4601,8 @@ function limitAt(e: Expr, x: string, point: number, direction: "left" | "right"
4083
4601
  // operand verbatim into the result) and a limit needing more than a
4084
4602
  // couple of rounds (e.g. most limits at infinity) blows up memory
4085
4603
  // long before the depth<12 cap is reached.
4086
- const df = Symbolic.simplify(diff(e.left, x));
4087
- const dg = Symbolic.simplify(diff(e.right, x));
4604
+ const df = simplifyCore(diff(e.left, x));
4605
+ const dg = simplifyCore(diff(e.right, x));
4088
4606
  return limitAt(div(df, dg), x, point, direction, depth + 1);
4089
4607
  }
4090
4608
  }
@@ -4092,12 +4610,19 @@ function limitAt(e: Expr, x: string, point: number, direction: "left" | "right"
4092
4610
  }
4093
4611
 
4094
4612
  // -- recursive-descent parser ----------------------------------------------
4613
+ /** Every name the parser treats as a function, longest first, so `asinh(` is not read as `a*sinh(`. */
4614
+ const FUNCTION_NAMES_LONGEST_FIRST: string[] = [...FUNCTION_NAMES].sort((a, b) => b.length - a.length);
4615
+
4095
4616
  class Parser {
4096
4617
  private readonly s: string;
4618
+ private readonly implicit: boolean;
4097
4619
  private pos = 0;
4098
4620
 
4099
- constructor(input: string) {
4100
- this.s = input.replace(/\s+/g, "");
4621
+ constructor(input: string, options: ParseOptions = {}) {
4622
+ this.implicit = options.implicitMultiplication === true;
4623
+ // With implicit multiplication, white space between two numbers is a product (`2 3`), not one number.
4624
+ const spaced = this.implicit ? input.replace(/(\d)\s+(?=[\d.])/g, "$1*") : input;
4625
+ this.s = spaced.replace(/\s+/g, "");
4101
4626
  }
4102
4627
 
4103
4628
  parse(): Expr {
@@ -4218,12 +4743,34 @@ class Parser {
4218
4743
 
4219
4744
  private term(): Expr {
4220
4745
  let left = this.factor();
4221
- while (this.peek() === "*" || this.peek() === "/") {
4222
- const op = this.s[this.pos++];
4223
- const right = this.factor();
4224
- left = op === "*" ? mul(left, right) : div(left, right);
4746
+ for (;;) {
4747
+ const ch = this.peek();
4748
+ if (ch === "*" || ch === "/") {
4749
+ this.pos++;
4750
+ const right = this.factor();
4751
+ left = ch === "*" ? mul(left, right) : div(left, right);
4752
+ } else if (this.implicit && /[a-zA-Z0-9.(]/.test(ch)) {
4753
+ left = mul(left, this.factor()); // juxtaposition: `2x`, `x y`, `2(x+1)`
4754
+ } else {
4755
+ return left;
4756
+ }
4225
4757
  }
4226
- return left;
4758
+ }
4759
+
4760
+ /**
4761
+ * The identifier at `this.pos` under implicit multiplication: a function name followed by `(`, `pi`, or a
4762
+ * single letter with an optional `_subscript`. A letter run like `xy` is therefore read one letter at a time.
4763
+ */
4764
+ private implicitIdentifier(): string {
4765
+ const rest = this.s.slice(this.pos);
4766
+ const run = /^[a-zA-Z]+/.exec(rest)?.[0] ?? "";
4767
+ const call = FUNCTION_NAMES_LONGEST_FIRST.find((f) => rest.startsWith(f) && rest[f.length] === "(");
4768
+ if (call) return call;
4769
+ const bare = FUNCTION_NAMES_LONGEST_FIRST.find((f) => f.length > 1 && run.startsWith(f));
4770
+ if (bare) throw new Error(`Function ${bare} needs parentheses: ${bare}(...)`);
4771
+ if (rest.startsWith("pi")) return "pi";
4772
+ const sub = /^[a-zA-Z]_(?:\d+|[a-zA-Z])/.exec(rest);
4773
+ return sub ? sub[0] : rest[0];
4227
4774
  }
4228
4775
 
4229
4776
  private factor(): Expr {
@@ -4261,9 +4808,11 @@ class Parser {
4261
4808
  return num(Number(numMatch[0]));
4262
4809
  }
4263
4810
  // identifier (function or variable or constant)
4264
- const idMatch = /^[a-zA-Z_]\w*/.exec(this.s.slice(this.pos));
4811
+ const idMatch = this.implicit
4812
+ ? /^[a-zA-Z_]/.exec(this.s.slice(this.pos))
4813
+ : /^[a-zA-Z_]\w*/.exec(this.s.slice(this.pos));
4265
4814
  if (idMatch) {
4266
- const name = idMatch[0];
4815
+ const name = this.implicit ? this.implicitIdentifier() : idMatch[0];
4267
4816
  this.pos += name.length;
4268
4817
  if ((name === "log" || name === "clamp") && this.peek() === "(") {
4269
4818
  this.pos++;