@johnhenry/math 0.0.3 → 0.2.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
@@ -8,6 +8,7 @@ import { FUNC_DERIVATIVE_RULES } from "./differentiation-rules.ts";
8
8
  import { MatrixMath } from "./MatrixMath.ts";
9
9
  import { Numerical } from "./Numerical.ts";
10
10
  import { Rational } from "./Rational.ts";
11
+ import { compileShader, type ShaderOptions, type ShaderResult } from "./Shader.ts";
11
12
  import { SpecialFunctions } from "./SpecialFunctions.ts";
12
13
  import type { Structure } from "./Structure.ts";
13
14
 
@@ -443,15 +444,38 @@ export interface OdeClosedFormResult {
443
444
  implicitRelation?: Expr;
444
445
  }
445
446
 
447
+ /** Options for {@link Symbolic.parse} and {@link Symbolic.fromLatex}. */
448
+ export interface ParseOptions {
449
+ /**
450
+ * Read juxtaposition as multiplication, the way people type maths: `2x`, `x y`, `xy`, `2(x+1)`,
451
+ * `(x+1)(x-1)`, `x(x+1)`, `3sin(x)`. Default `false`, which keeps the strict grammar (`2x` is an
452
+ * error and `xy` is one variable named `xy`).
453
+ *
454
+ * With this on, a run of letters that is not a function call (`name(`) or `pi` is split into
455
+ * single-letter variables, so `xy` is `x*y` and multi-letter variable names such as `theta` are
456
+ * unavailable (use a subscript: `x_1` or `x_a`, which stays one variable). A letter followed by
457
+ * digits is a product too (`x3` is `x*3`). A function name must be followed by `(`. Space
458
+ * between two numbers multiplies (`2 3` is `6`). Juxtaposition has the same precedence as `*`
459
+ * and `/` and binds left to right, so `1/2x` is `(1/2)*x`; `^` binds tighter (`2x^2` is `2*(x^2)`).
460
+ * For `fromLatex` it also lets a function with no brackets take a whole product as its argument
461
+ * (`\sin 2x` is `sin(2*x)`, `\sin x\cos x` is `sin(x)*cos(x)`), and `^` take one token (`x^23` is
462
+ * `x^2*3`, as TeX reads it; `x^{23}` is `x^23`).
463
+ */
464
+ implicitMultiplication?: boolean;
465
+ }
466
+
446
467
  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();
468
+ /**
469
+ * Parse an expression string such as `"sin(x^2) + 3*x"` into an AST.
470
+ * Pass `{ implicitMultiplication: true }` to also read `2x`, `x y` and `2(x+1)`; see {@link ParseOptions}.
471
+ */
472
+ static parse(input: string, options: ParseOptions = {}): Expr {
473
+ return new Parser(input, options ?? {}).parse();
450
474
  }
451
475
 
452
476
  static differentiate(expr: Expr | string, variable = "x"): Expr {
453
477
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
454
- return Symbolic.simplify(diff(e, variable));
478
+ return simplifyCore(diff(e, variable));
455
479
  }
456
480
 
457
481
  /**
@@ -465,17 +489,21 @@ export class Symbolic {
465
489
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
466
490
  const steps: DifferentiationStep[] = [];
467
491
  const raw = diffTraced(e, variable, steps);
468
- return { steps, result: Symbolic.simplify(raw) };
492
+ return { steps, result: simplifyCore(raw) };
469
493
  }
470
494
 
495
+ /**
496
+ * Algebraic simplification: constant folding, like-term collection, and, as a last
497
+ * step, expanding the expression as a rational function and cancelling common
498
+ * factors -- `(x+1)^2 - x^2 - 2*x` gives `1` and `(x^2-1)/(x-1)` gives `x+1`.
499
+ * The expanded/cancelled form is used only when it is strictly smaller than the
500
+ * collected form, so a product such as `(x+1)*2` keeps its shape. Non-polynomial
501
+ * subexpressions (`sin(x)`, `x^y`) are treated as opaque symbols. Like any CAS
502
+ * cancellation, `(x^2-1)/(x-1)` is `x+1` as an expression, ignoring that the
503
+ * original is undefined at `x = 1`.
504
+ */
471
505
  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;
506
+ return normalizeRational(simplifyCore(expr));
479
507
  }
480
508
 
481
509
  /**
@@ -495,15 +523,15 @@ export class Symbolic {
495
523
  * assumptions (e.g. "x+y is positive" isn't expressible).
496
524
  */
497
525
  static simplifyAssuming(expr: Expr | string, assumptions: Record<string, Assumption>): Expr {
498
- const simplified = Symbolic.simplify(expr);
526
+ const simplified = simplifyCore(expr);
499
527
  const rewritten = applyAssumptions(simplified, assumptions);
500
- return Symbolic.simplify(rewritten);
528
+ return simplifyCore(rewritten);
501
529
  }
502
530
 
503
531
  /** Symbolic anti-derivative (elementary rules; throws {@link NotIntegrableError} otherwise). */
504
532
  static integrate(expr: Expr | string, variable = "x"): Expr {
505
533
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
506
- return Symbolic.simplify(integ(e, variable));
534
+ return simplifyCore(integ(e, variable));
507
535
  }
508
536
 
509
537
  /**
@@ -542,13 +570,13 @@ export class Symbolic {
542
570
  variable = "x",
543
571
  env: Record<string, number> = {},
544
572
  ): number {
545
- const e = Symbolic.simplify(typeof expr === "string" ? Symbolic.parse(expr) : expr);
573
+ const e = simplifyCore(typeof expr === "string" ? Symbolic.parse(expr) : expr);
546
574
  const probe = compileExpr(e);
547
575
  if (hasInteriorSingularity((val: number) => probe({ ...env, [variable]: val }), lower, upper)) {
548
576
  throw new IntegrationSingularityError();
549
577
  }
550
578
  try {
551
- const F = Symbolic.simplify(integ(e, variable));
579
+ const F = simplifyCore(integ(e, variable));
552
580
  const at = (val: number) => evalExpr(F, { ...env, [variable]: val });
553
581
  return at(upper) - at(lower);
554
582
  } catch (err) {
@@ -608,7 +636,7 @@ export class Symbolic {
608
636
  xVar = "x",
609
637
  yVar = "y",
610
638
  ): OdeClosedFormResult {
611
- const e = Symbolic.simplify(typeof dyDxExpr === "string" ? Symbolic.parse(dyDxExpr) : dyDxExpr);
639
+ const e = simplifyCore(typeof dyDxExpr === "string" ? Symbolic.parse(dyDxExpr) : dyDxExpr);
612
640
  const sep = matchSeparable(e, xVar, yVar);
613
641
  if (sep) return solveSeparableOde(sep, xVar, yVar, x0, y0);
614
642
  const lin = matchLinearOde(e, xVar, yVar);
@@ -724,14 +752,14 @@ export class Symbolic {
724
752
  }
725
753
  term = diff(term, variable);
726
754
  }
727
- return Symbolic.simplify(result);
755
+ return simplifyCore(result);
728
756
  }
729
757
 
730
758
  /** Replace every occurrence of a variable with a sub-expression (e.g. for changing variables or plugging in a solved value). */
731
759
  static substitute(expr: Expr | string, variable: string, replacement: Expr | string): Expr {
732
760
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
733
761
  const r = typeof replacement === "string" ? Symbolic.parse(replacement) : replacement;
734
- return Symbolic.simplify(subst(e, variable, r));
762
+ return simplifyCore(subst(e, variable, r));
735
763
  }
736
764
 
737
765
  /** Distribute multiplication over addition/subtraction and expand small integer powers of sums, e.g. `(x+1)^2 -> x^2+2*x+1`. */
@@ -739,7 +767,7 @@ export class Symbolic {
739
767
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
740
768
  let cur = e;
741
769
  for (let i = 0; i < 30; i++) {
742
- const next = Symbolic.simplify(expandOnce(cur));
770
+ const next = simplifyCore(expandOnce(cur));
743
771
  if (equal(next, cur)) return next;
744
772
  cur = next;
745
773
  }
@@ -837,6 +865,23 @@ export class Symbolic {
837
865
  return render(typeof expr === "string" ? Symbolic.parse(expr) : expr, 0);
838
866
  }
839
867
 
868
+ /**
869
+ * Compile `expr` to a GLSL ES 3.0 expression over `float` (default) or, with
870
+ * `{ complex: true }`, over `vec2` complex numbers (the helpers it needs, such as
871
+ * `mx_cmul`, are returned in `helpers` and emitted once). Output is deterministic.
872
+ * Refuses nodes with no shader form (`sum`, `product`, `erf`, `gcd`, `lcm`, and anything
873
+ * not closed under the complex numbers in complex mode) with a `ShaderUnsupportedError`.
874
+ * Shader `pow` of a negative base with a non-integer exponent is undefined, unlike JS `NaN`.
875
+ */
876
+ static toGLSL(expr: Expr | string, options: ShaderOptions = {}): ShaderResult {
877
+ return compileShader(typeof expr === "string" ? Symbolic.parse(expr) : expr, "glsl", options);
878
+ }
879
+
880
+ /** As {@link toGLSL}, for WGSL (`f32` / `vec2<f32>`; `select` replaces the ternary). */
881
+ static toWGSL(expr: Expr | string, options: ShaderOptions = {}): ShaderResult {
882
+ return compileShader(typeof expr === "string" ? Symbolic.parse(expr) : expr, "wgsl", options);
883
+ }
884
+
840
885
  /** Render `expr` as a LaTeX string, e.g. `"x/2"` -> `"\\frac{x}{2}"`. */
841
886
  static toLatex(expr: Expr | string): string {
842
887
  return toLatexRec(typeof expr === "string" ? Symbolic.parse(expr) : expr, 0);
@@ -852,12 +897,16 @@ export class Symbolic {
852
897
  * commands (`\sin`, `\arcsin`, `\sinh`, `\operatorname{sech}`, ...), and the
853
898
  * two-argument `BinaryFuncName` commands (`\min`, `\max`, `\gcd`,
854
899
  * `\operatorname{atan2}`, `\operatorname{hypot}`, `\operatorname{lcm}`).
855
- * Spacing commands (`\,`, `\;`, `\!`, `\quad`) are ignored.
900
+ * Spacing commands (`\,`, `\;`, `\!`, `\quad`) are ignored. Single-token arguments as TeX
901
+ * reads them (`\frac12`, `\frac1x`, `\sqrt2`) and a power on a function command
902
+ * (`\sin^{2}(x)` is `sin(x)^2`) are accepted. An exponent of `-1` on a function command (`\sin^{-1}`) is
903
+ * refused as ambiguous (inverse function or reciprocal): write `\arcsin(x)` or `(\sin x)^{-1}`. For what people type into a math field
904
+ * (`2x`, `\sin 2x`, `(x+1)(x-1)`, `x^23`) pass `{ implicitMultiplication: true }`; see {@link ParseOptions}.
856
905
  *
857
906
  * @throws on LaTeX constructs with no `Expr` equivalent, e.g. `\int` or `\sum`.
858
907
  */
859
- static fromLatex(latex: string): Expr {
860
- return Symbolic.parse(latexToInfix(latex));
908
+ static fromLatex(latex: string, options: ParseOptions = {}): Expr {
909
+ return Symbolic.parse(latexToInfix(latex, options), options);
861
910
  }
862
911
 
863
912
  /**
@@ -878,11 +927,11 @@ export class Symbolic {
878
927
  */
879
928
  static solve(expr: Expr | string, variable = "x"): Expr[] {
880
929
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
881
- const coeffs = polynomialCoeffs(Symbolic.simplify(e), variable, 6);
930
+ const coeffs = polynomialCoeffs(simplifyCore(e), variable, 6);
882
931
  if (!coeffs) {
883
932
  throw new Error(`solve: expression is not a polynomial in "${variable}" of degree ≤ 6.`);
884
933
  }
885
- return solvePolynomial(coeffs).map((r) => Symbolic.simplify(r));
934
+ return solvePolynomial(coeffs).map((r) => simplifyCore(r));
886
935
  }
887
936
 
888
937
  /**
@@ -900,7 +949,7 @@ export class Symbolic {
900
949
  * @throws {SingularSystemError} if the coefficient matrix is singular/rank-deficient.
901
950
  */
902
951
  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));
952
+ const exprs = equations.map((eq) => simplifyCore(typeof eq === "string" ? Symbolic.parse(eq) : eq));
904
953
  if (exprs.length !== variables.length) {
905
954
  throw new Error(
906
955
  `solveSystem: expected ${variables.length} equations for ${variables.length} variables, got ${exprs.length}.`,
@@ -1149,7 +1198,7 @@ export class Symbolic {
1149
1198
  */
1150
1199
  static factor(expr: Expr | string, variable = "x"): Expr {
1151
1200
  const e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
1152
- const simplified = Symbolic.simplify(e);
1201
+ const simplified = simplifyCore(e);
1153
1202
  const coeffs = polynomialCoeffs(simplified, variable, 6);
1154
1203
  if (!coeffs) return simplified;
1155
1204
  return factorPolynomial(coeffs, variable);
@@ -1368,6 +1417,17 @@ function diff(e: Expr, x: string): Expr {
1368
1417
  }
1369
1418
 
1370
1419
  // -- simplification ---------------------------------------------------------
1420
+ /** Constant folding and like-term collection to a fixed point -- no expansion or cancellation. */
1421
+ function simplifyCore(expr: Expr | string): Expr {
1422
+ let e = typeof expr === "string" ? Symbolic.parse(expr) : expr;
1423
+ for (let i = 0; i < 30; i++) {
1424
+ const next = collectLikeTerms(simplifyOnce(e));
1425
+ if (equal(next, e)) return next;
1426
+ e = next;
1427
+ }
1428
+ return e;
1429
+ }
1430
+
1371
1431
  function simplifyOnce(e: Expr): Expr {
1372
1432
  if (e.type === "const" || e.type === "var") return e;
1373
1433
  if (e.type === "neg") {
@@ -2115,7 +2175,7 @@ function freshVariableName(e: Expr, x: string): string {
2115
2175
  * legitimately hit a domain error/NaN on either side).
2116
2176
  */
2117
2177
  function verifyByDifferentiation(candidate: Expr, e: Expr, x: string): boolean {
2118
- const check = Symbolic.simplify(diff(candidate, x));
2178
+ const check = simplifyCore(diff(candidate, x));
2119
2179
  let agreements = 0;
2120
2180
  for (const p of [0.7, 1.3, -0.9]) {
2121
2181
  const expected = evalExpr(e, { [x]: p });
@@ -2140,16 +2200,16 @@ function verifyByDifferentiation(candidate: Expr, e: Expr, x: string): boolean {
2140
2200
  */
2141
2201
  function tryUSubstitution(e: Expr, x: string): Expr | null {
2142
2202
  for (const g of collectSubstitutionCandidates(e, x)) {
2143
- const gPrime = Symbolic.simplify(diff(g, x));
2203
+ const gPrime = simplifyCore(diff(g, x));
2144
2204
  if (isConst(gPrime, 0)) continue;
2145
- const quotient = Symbolic.simplify(divideOutFactors(e, gPrime) ?? div(e, gPrime));
2205
+ const quotient = simplifyCore(divideOutFactors(e, gPrime) ?? div(e, gPrime));
2146
2206
  if (!containsExpr(quotient, g)) continue;
2147
2207
  const uName = freshVariableName(quotient, x);
2148
2208
  const substituted = substExpr(quotient, g, v(uName));
2149
2209
  if (containsVar(substituted, x)) continue;
2150
2210
  try {
2151
2211
  const antiderivativeInU = integRules(substituted, uName);
2152
- const candidate = Symbolic.simplify(substExpr(antiderivativeInU, v(uName), g));
2212
+ const candidate = simplifyCore(substExpr(antiderivativeInU, v(uName), g));
2153
2213
  if (verifyByDifferentiation(candidate, e, x)) return candidate;
2154
2214
  } catch (err) {
2155
2215
  if (!(err instanceof NotIntegrableError)) throw err;
@@ -2270,11 +2330,11 @@ function matchSeparable(e: Expr, xVar: string, yVar: string): { g: Expr; h: Expr
2270
2330
  */
2271
2331
  function matchLinearOde(e: Expr, xVar: string, yVar: string): { p: Expr; q: Expr } | null {
2272
2332
  if (!containsVar(e, yVar)) return null; // handled by matchSeparable's h(y)=1 case instead
2273
- const negP = Symbolic.simplify(diff(e, yVar));
2333
+ const negP = simplifyCore(diff(e, yVar));
2274
2334
  if (containsVar(negP, yVar)) return null; // not affine in y
2275
- const q = Symbolic.simplify(sub(e, mul(negP, v(yVar))));
2335
+ const q = simplifyCore(sub(e, mul(negP, v(yVar))));
2276
2336
  if (containsVar(q, yVar)) return null; // sanity check
2277
- const p = Symbolic.simplify(neg(negP));
2337
+ const p = simplifyCore(neg(negP));
2278
2338
  // Conservative, matching matchSeparable's philosophy: p/q must depend on
2279
2339
  // no free variable other than xVar (an extra symbolic parameter, or a
2280
2340
  // stray reference to yVar that survived simplification, both bail out).
@@ -2337,21 +2397,21 @@ function solveSeparableOde(
2337
2397
  x0: number,
2338
2398
  y0: number,
2339
2399
  ): OdeClosedFormResult {
2340
- const invH = Symbolic.simplify(div(num(1), sep.h));
2400
+ const invH = simplifyCore(div(num(1), sep.h));
2341
2401
  let H: Expr;
2342
2402
  let G: Expr;
2343
2403
  try {
2344
- H = Symbolic.simplify(integ(invH, yVar));
2345
- G = Symbolic.simplify(integ(sep.g, xVar));
2404
+ H = simplifyCore(integ(invH, yVar));
2405
+ G = simplifyCore(integ(sep.g, xVar));
2346
2406
  } catch (err) {
2347
2407
  if (err instanceof NotIntegrableError) throw new NoClosedFormError();
2348
2408
  throw err;
2349
2409
  }
2350
2410
  const C = evalExpr(H, { [yVar]: y0 }) - evalExpr(G, { [xVar]: x0 });
2351
- const rhs = Symbolic.simplify(add(G, num(C)));
2411
+ const rhs = simplifyCore(add(G, num(C)));
2352
2412
  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)) };
2413
+ if (explicitY) return { explicit: true, y: simplifyCore(explicitY) };
2414
+ return { explicit: false, implicitRelation: simplifyCore(sub(H, rhs)) };
2355
2415
  }
2356
2416
 
2357
2417
  function solveLinearOde(
@@ -2365,9 +2425,9 @@ function solveLinearOde(
2365
2425
  let mu: Expr;
2366
2426
  let muQIntegral: Expr;
2367
2427
  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));
2428
+ integralP = simplifyCore(integ(lin.p, xVar));
2429
+ mu = simplifyCore(fn("exp", integralP));
2430
+ muQIntegral = simplifyCore(integ(simplifyCore(mul(mu, lin.q)), xVar));
2371
2431
  } catch (err) {
2372
2432
  if (err instanceof NotIntegrableError) throw new NoClosedFormError();
2373
2433
  throw err;
@@ -2376,7 +2436,7 @@ function solveLinearOde(
2376
2436
  const muAtX0 = evalExpr(mu, { [xVar]: x0 });
2377
2437
  const muQIntegralAtX0 = evalExpr(muQIntegral, { [xVar]: x0 });
2378
2438
  const C = y0 * muAtX0 - muQIntegralAtX0;
2379
- const y = Symbolic.simplify(div(add(muQIntegral, num(C)), mu));
2439
+ const y = simplifyCore(div(add(muQIntegral, num(C)), mu));
2380
2440
  return { explicit: true, y };
2381
2441
  }
2382
2442
 
@@ -2429,7 +2489,7 @@ function matchHomogeneous(e: Expr, xVar: string, yVar: string): { vName: string;
2429
2489
  }
2430
2490
  }
2431
2491
  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)));
2492
+ const F = simplifyCore(subst(substituted, xVar, num(1)));
2433
2493
  return { vName, F };
2434
2494
  }
2435
2495
 
@@ -2461,15 +2521,15 @@ function solveHomogeneousOde(
2461
2521
  ): OdeClosedFormResult {
2462
2522
  const { vName, F } = hom;
2463
2523
  const v0 = y0 / x0;
2464
- const FMinusV = Symbolic.simplify(sub(F, v(vName)));
2524
+ const FMinusV = simplifyCore(sub(F, v(vName)));
2465
2525
  if (FMinusV.type === "const" && FMinusV.value === 0) {
2466
- return { explicit: true, y: Symbolic.simplify(mul(num(v0), v(xVar))) };
2526
+ return { explicit: true, y: simplifyCore(mul(num(v0), v(xVar))) };
2467
2527
  }
2468
2528
  const sepResult = solveSeparableOde({ g: div(num(1), v(xVar)), h: FMinusV }, xVar, vName, x0, v0);
2469
2529
  if (sepResult.explicit && sepResult.y) {
2470
- return { explicit: true, y: Symbolic.simplify(mul(sepResult.y, v(xVar))) };
2530
+ return { explicit: true, y: simplifyCore(mul(sepResult.y, v(xVar))) };
2471
2531
  }
2472
- const implicitRelation = Symbolic.simplify(subst(sepResult.implicitRelation as Expr, vName, div(v(yVar), v(xVar))));
2532
+ const implicitRelation = simplifyCore(subst(sepResult.implicitRelation as Expr, vName, div(v(yVar), v(xVar))));
2473
2533
  return { explicit: false, implicitRelation };
2474
2534
  }
2475
2535
 
@@ -2554,8 +2614,8 @@ function matchBernoulli(e: Expr, xVar: string, yVar: string): { p: Expr; q: Expr
2554
2614
  return null;
2555
2615
  }
2556
2616
  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;
2617
+ const q = powSign === 1 ? powMatch.coef : simplifyCore(neg(powMatch.coef));
2618
+ const p = linSign === 1 ? simplifyCore(neg(linMatch.coef)) : linMatch.coef;
2559
2619
  return { p, q, n: powMatch.n };
2560
2620
  }
2561
2621
 
@@ -2582,10 +2642,10 @@ function solveBernoulliOde(
2582
2642
  ): OdeClosedFormResult {
2583
2643
  const oneMinusN = 1 - bern.n;
2584
2644
  const w0 = y0 ** oneMinusN;
2585
- const transformedP = Symbolic.simplify(mul(num(oneMinusN), bern.p));
2586
- const transformedQ = Symbolic.simplify(mul(num(oneMinusN), bern.q));
2645
+ const transformedP = simplifyCore(mul(num(oneMinusN), bern.p));
2646
+ const transformedQ = simplifyCore(mul(num(oneMinusN), bern.q));
2587
2647
  const wResult = solveLinearOde({ p: transformedP, q: transformedQ }, xVar, yVar, x0, w0);
2588
- const y = Symbolic.simplify(pow(wResult.y as Expr, num(1 / oneMinusN)));
2648
+ const y = simplifyCore(pow(wResult.y as Expr, num(1 / oneMinusN)));
2589
2649
  return { explicit: true, y };
2590
2650
  }
2591
2651
 
@@ -2607,7 +2667,7 @@ function matchExact(e: Expr, xVar: string, yVar: string): { M: Expr; N: Expr } |
2607
2667
  const vars = collectFreeVars(e);
2608
2668
  if (![...vars].every((n) => n === xVar || n === yVar)) return null;
2609
2669
  const M = e.left;
2610
- const N = Symbolic.simplify(neg(e.right));
2670
+ const N = simplifyCore(neg(e.right));
2611
2671
  const dMdy = diff(M, yVar);
2612
2672
  const dNdx = diff(N, xVar);
2613
2673
  let agreements = 0;
@@ -2662,8 +2722,8 @@ function solveExactOde(
2662
2722
  let g: Expr;
2663
2723
  let gPrime: Expr;
2664
2724
  try {
2665
- F0 = Symbolic.simplify(integ(M, xVar));
2666
- gPrime = Symbolic.simplify(sub(N, diff(F0, yVar)));
2725
+ F0 = simplifyCore(integ(M, xVar));
2726
+ gPrime = simplifyCore(sub(N, diff(F0, yVar)));
2667
2727
  if (containsVar(gPrime, xVar)) {
2668
2728
  // Symbolic cancellation fell short -- verify x-independence
2669
2729
  // numerically, then pin x to a probe value that evaluates finitely.
@@ -2675,18 +2735,18 @@ function solveExactOde(
2675
2735
  if (Math.abs(atProbe - atOther) > 1e-6 * Math.max(1, Math.abs(atProbe))) {
2676
2736
  throw new NoClosedFormError();
2677
2737
  }
2678
- pinned = Symbolic.simplify(subst(gPrime, xVar, num(xp)));
2738
+ pinned = simplifyCore(subst(gPrime, xVar, num(xp)));
2679
2739
  break;
2680
2740
  }
2681
2741
  if (!pinned) throw new NoClosedFormError();
2682
2742
  gPrime = pinned;
2683
2743
  }
2684
- g = Symbolic.simplify(integ(gPrime, yVar));
2744
+ g = simplifyCore(integ(gPrime, yVar));
2685
2745
  } catch (err) {
2686
2746
  if (err instanceof NotIntegrableError) throw new NoClosedFormError();
2687
2747
  throw err;
2688
2748
  }
2689
- const F = Symbolic.simplify(add(F0, g));
2749
+ const F = simplifyCore(add(F0, g));
2690
2750
  const C = evalExpr(F, { [xVar]: x0, [yVar]: y0 });
2691
2751
  if (!Number.isFinite(C)) throw new NoClosedFormError();
2692
2752
  // Cheap explicit attempt: F = H(y) + G(x) (either order) inverts via the
@@ -2701,12 +2761,12 @@ function solveExactOde(
2701
2761
  ] as const) {
2702
2762
  if (!isPureFunctionOf(yPart, yOnly) || !isPureFunctionOf(xPartSigned, xOnly)) continue;
2703
2763
  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));
2764
+ const rhs = simplifyCore(sub(num(C), xPartSigned));
2705
2765
  const explicitY = tryInvertForY(yPart, yVar, rhs, y0);
2706
- if (explicitY) return { explicit: true, y: Symbolic.simplify(explicitY) };
2766
+ if (explicitY) return { explicit: true, y: simplifyCore(explicitY) };
2707
2767
  }
2708
2768
  }
2709
- return { explicit: false, implicitRelation: Symbolic.simplify(sub(F, num(C))) };
2769
+ return { explicit: false, implicitRelation: simplifyCore(sub(F, num(C))) };
2710
2770
  }
2711
2771
 
2712
2772
  /** If `e` is `a·x + b` (a, b constant), return `{ a, b }`, else `null`. */
@@ -3604,12 +3664,47 @@ const CMP_LATEX_CMDS: Record<string, string> = {
3604
3664
  };
3605
3665
 
3606
3666
  /** 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);
3667
+ function latexToInfix(latex: string, opts: ParseOptions = {}): string {
3668
+ const cleaned = latex.replace(/\\left|\\right/g, "").replace(/(?<!\\)\\(?:,|;|!|quad|qquad| )/g, "");
3669
+ return transformLatex(cleaned, opts);
3610
3670
  }
3611
3671
 
3612
- function transformLatex(s: string): string {
3672
+ /**
3673
+ * Where the unbracketed argument of a function command ends under implicit multiplication: the longest
3674
+ * product of numbers, letters, `\\pi`, powers, subscripts and bracket groups starting at `start`
3675
+ * (`\\sin 2x` takes `2x`; `\\sin x\\cos x` takes just `x`). Returns `start` when there is none.
3676
+ */
3677
+ function bareArgumentEnd(s: string, start: number): number {
3678
+ let j = start;
3679
+ const group = (at: number): number => {
3680
+ const open = s[at];
3681
+ return findGroupEnd(s, at, open, open === "{" ? "}" : ")") + 1;
3682
+ };
3683
+ while (j < s.length) {
3684
+ const c = s[j];
3685
+ if (/[A-Za-z0-9.]/.test(c)) {
3686
+ j++;
3687
+ } else if (c === "(" || c === "{") {
3688
+ j = group(j);
3689
+ } else if (c === "_" && s[j + 1] === "{") {
3690
+ j = group(j + 1);
3691
+ } else if (c === "^") {
3692
+ if (s[j + 1] === "{") j = group(j + 1);
3693
+ else if (s[j + 1] === "\\") j += 1 + (/^\\[a-zA-Z]+/.exec(s.slice(j + 1))?.[0].length ?? 1);
3694
+ else j += 2;
3695
+ } else if (/^\\pi(?![a-zA-Z])/.test(s.slice(j))) {
3696
+ j += 3;
3697
+ } else if (c === " " && /^ +(?:[A-Za-z0-9.({]|\\pi(?![a-zA-Z]))/.test(s.slice(j))) {
3698
+ j += s.slice(j).length - s.slice(j).trimStart().length;
3699
+ } else {
3700
+ break;
3701
+ }
3702
+ }
3703
+ return j;
3704
+ }
3705
+
3706
+ function transformLatex(s: string, opts: ParseOptions = {}): string {
3707
+ const implicit = opts.implicitMultiplication === true;
3613
3708
  let out = "";
3614
3709
  let i = 0;
3615
3710
 
@@ -3621,11 +3716,20 @@ function transformLatex(s: string): string {
3621
3716
  };
3622
3717
 
3623
3718
  const readGroupOrParenOrToken = (): string => {
3719
+ while (s[i] === " ") i++;
3624
3720
  if (s[i] === "{" || s[i] === "(") {
3625
3721
  const { content, next } = extractGroup(s, i);
3626
3722
  i = next;
3627
3723
  return content;
3628
3724
  }
3725
+ if (implicit) {
3726
+ const end = bareArgumentEnd(s, i);
3727
+ if (end > i) {
3728
+ const run = s.slice(i, end).trim();
3729
+ i = end;
3730
+ return run;
3731
+ }
3732
+ }
3629
3733
  const m = /^[a-zA-Z_]\w*|^\d+\.?\d*(?:[eE][+-]?\d+)?/.exec(s.slice(i));
3630
3734
  if (m) {
3631
3735
  i += m[0].length;
@@ -3634,6 +3738,32 @@ function transformLatex(s: string): string {
3634
3738
  throw new Error(`Expected an argument at position ${i} in LaTeX source: ${s}`);
3635
3739
  };
3636
3740
 
3741
+ /** A `{...}` group, or the one token TeX takes without braces: a digit, a letter or a `\\command`. */
3742
+ const readGroupOrToken = (): string => {
3743
+ while (s[i] === " ") i++;
3744
+ if (s[i] === "{") return readGroup();
3745
+ const m = /^(?:\\[a-zA-Z]+|[a-zA-Z0-9])/.exec(s.slice(i));
3746
+ if (!m) throw new Error(`Expected an argument at position ${i} in LaTeX source: ${s}`);
3747
+ i += m[0].length;
3748
+ return m[0];
3749
+ };
3750
+
3751
+ /** A power written on a function command (`\\sin^{2}`, `\\sin^2`), or null. */
3752
+ const readFunctionPower = (): string | null => {
3753
+ if (s[i] !== "^") return null;
3754
+ i++;
3755
+ const power = readGroupOrToken();
3756
+ // `\sin^{-1}` is arcsin in most textbooks and calculators but 1/sin in strict TeX/notation; guessing
3757
+ // wrong would silently change the maths, so it is refused rather than interpreted.
3758
+ if (/^\s*-\s*1\s*$/.test(power)) {
3759
+ throw new Error(
3760
+ "Ambiguous LaTeX: a function with exponent -1 (e.g. \\sin^{-1}) may mean the inverse function or the reciprocal; " +
3761
+ "write \\arcsin(x) for the inverse, or (\\sin x)^{-1} / 1/\\sin x for the reciprocal",
3762
+ );
3763
+ }
3764
+ return power;
3765
+ };
3766
+
3637
3767
  /** Read a `(...)`/`{...}` group holding exactly 2 comma-separated arguments, for `call2` functions. */
3638
3768
  const readArgPair = (): [string, string] => {
3639
3769
  if (s[i] !== "(" && s[i] !== "{") throw new Error(`Expected '(' at position ${i} in LaTeX source: ${s}`);
@@ -3683,9 +3813,10 @@ function transformLatex(s: string): string {
3683
3813
  const [exprPart, condPart] = cols;
3684
3814
  if (condPart.trim() === "\\text{otherwise}") {
3685
3815
  if (otherwise !== null) throw new Error("\\begin{cases} may have only one \\text{otherwise} row");
3686
- otherwise = transformLatex(exprPart);
3816
+ otherwise = transformLatex(exprPart, opts);
3687
3817
  } else {
3688
- parts.push(`${transformLatex(condPart)}, ${transformLatex(exprPart)}`);
3818
+ const condition = condPart.replace(/^\s*\\text\{\s*(?:if|when)\s*\}/, ""); // "x & \text{if } x<0" as MathLive writes it
3819
+ parts.push(`${transformLatex(condition, opts)}, ${transformLatex(exprPart, opts)}`);
3689
3820
  }
3690
3821
  }
3691
3822
  if (otherwise === null) {
@@ -3694,28 +3825,28 @@ function transformLatex(s: string): string {
3694
3825
  out += `piecewise(${parts.concat(otherwise).join(", ")})`;
3695
3826
  continue;
3696
3827
  }
3697
- if (cmd === "\\frac") {
3698
- const numerator = readGroup();
3699
- const denominator = readGroup();
3700
- out += `((${transformLatex(numerator)})/(${transformLatex(denominator)}))`;
3828
+ if (cmd === "\\frac" || cmd === "\\dfrac" || cmd === "\\tfrac") {
3829
+ const numerator = readGroupOrToken();
3830
+ const denominator = readGroupOrToken();
3831
+ out += `((${transformLatex(numerator, opts)})/(${transformLatex(denominator, opts)}))`;
3701
3832
  continue;
3702
3833
  }
3703
3834
  if (cmd === "\\sqrt") {
3704
3835
  if (s[i] === "[") {
3705
3836
  const { content: root, next } = extractGroup(s, i);
3706
3837
  i = next;
3707
- const arg = readGroup();
3708
- const transformedRoot = transformLatex(root).trim();
3838
+ const arg = readGroupOrToken();
3839
+ const transformedRoot = transformLatex(root, opts).trim();
3709
3840
  // \sqrt[3]{...} maps to cbrt(...) rather than a fractional power so
3710
3841
  // negative arguments evaluate correctly (Math.cbrt(-8) = -2, but
3711
3842
  // (-8)^(1/3) is NaN under JS's `**`).
3712
3843
  out +=
3713
3844
  transformedRoot === "3"
3714
- ? `cbrt(${transformLatex(arg)})`
3715
- : `((${transformLatex(arg)})^(1/(${transformedRoot})))`;
3845
+ ? `cbrt(${transformLatex(arg, opts)})`
3846
+ : `((${transformLatex(arg, opts)})^(1/(${transformedRoot})))`;
3716
3847
  } else {
3717
- const arg = readGroup();
3718
- out += `sqrt(${transformLatex(arg)})`;
3848
+ const arg = readGroupOrToken();
3849
+ out += `sqrt(${transformLatex(arg, opts)})`;
3719
3850
  }
3720
3851
  continue;
3721
3852
  }
@@ -3729,8 +3860,10 @@ function transformLatex(s: string): string {
3729
3860
  if (base !== "10" && base !== "2") {
3730
3861
  throw new Error(`Unsupported LaTeX construct: \\log base ${base} (only base 10 and base 2 are supported)`);
3731
3862
  }
3863
+ const power = readFunctionPower();
3732
3864
  const arg = readGroupOrParenOrToken();
3733
- out += `${base === "2" ? "log2" : "log10"}(${transformLatex(arg)})`;
3865
+ const call = `${base === "2" ? "log2" : "log10"}(${transformLatex(arg, opts)})`;
3866
+ out += power === null ? call : `${call}^(${transformLatex(power, opts)})`;
3734
3867
  continue;
3735
3868
  }
3736
3869
  if (cmd === "\\lfloor" || cmd === "\\lceil") {
@@ -3739,7 +3872,7 @@ function transformLatex(s: string): string {
3739
3872
  if (closeIdx === -1) throw new Error(`Unmatched '${cmd}' in LaTeX source`);
3740
3873
  const inner = s.slice(i, closeIdx);
3741
3874
  i = closeIdx + closeCmd.length;
3742
- out += `${cmd === "\\lfloor" ? "floor" : "ceil"}(${transformLatex(inner)})`;
3875
+ out += `${cmd === "\\lfloor" ? "floor" : "ceil"}(${transformLatex(inner, opts)})`;
3743
3876
  continue;
3744
3877
  }
3745
3878
  if (cmd === "\\operatorname") {
@@ -3747,25 +3880,29 @@ function transformLatex(s: string): string {
3747
3880
  const binaryName = OPERATORNAME_BINARY_FUNC_NAMES[opName];
3748
3881
  if (binaryName) {
3749
3882
  const [left, right] = readArgPair();
3750
- out += `${binaryName}(${transformLatex(left)}, ${transformLatex(right)})`;
3883
+ out += `${binaryName}(${transformLatex(left, opts)}, ${transformLatex(right, opts)})`;
3751
3884
  continue;
3752
3885
  }
3753
3886
  const funcName = OPERATORNAME_FUNC_NAMES[opName];
3754
3887
  if (!funcName) throw new Error(`Unsupported LaTeX construct: \\operatorname{${opName}}`);
3888
+ const power = readFunctionPower();
3755
3889
  const arg = readGroupOrParenOrToken();
3756
- out += `${funcName}(${transformLatex(arg)})`;
3890
+ const call = `${funcName}(${transformLatex(arg, opts)})`;
3891
+ out += power === null ? call : `${call}^(${transformLatex(power, opts)})`;
3757
3892
  continue;
3758
3893
  }
3759
3894
  const binaryFuncName = LATEX_BINARY_FUNC_NAMES[cmd];
3760
3895
  if (binaryFuncName) {
3761
3896
  const [left, right] = readArgPair();
3762
- out += `${binaryFuncName}(${transformLatex(left)}, ${transformLatex(right)})`;
3897
+ out += `${binaryFuncName}(${transformLatex(left, opts)}, ${transformLatex(right, opts)})`;
3763
3898
  continue;
3764
3899
  }
3765
3900
  const funcName = LATEX_FUNC_NAMES[cmd];
3766
3901
  if (funcName) {
3902
+ const power = readFunctionPower();
3767
3903
  const arg = readGroupOrParenOrToken();
3768
- out += `${funcName}(${transformLatex(arg)})`;
3904
+ const call = `${funcName}(${transformLatex(arg, opts)})`;
3905
+ out += power === null ? call : `${call}^(${transformLatex(power, opts)})`;
3769
3906
  continue;
3770
3907
  }
3771
3908
  throw new Error(`Unsupported LaTeX construct: ${cmd}`);
@@ -3775,13 +3912,20 @@ function transformLatex(s: string): string {
3775
3912
  if (closeIdx === -1) throw new Error("Unmatched '|' in LaTeX source");
3776
3913
  const inner = s.slice(i + 1, closeIdx);
3777
3914
  i = closeIdx + 1;
3778
- out += `abs(${transformLatex(inner)})`;
3915
+ out += `abs(${transformLatex(inner, opts)})`;
3916
+ continue;
3917
+ }
3918
+ if (implicit && ch === "^" && /^[a-zA-Z0-9]|^\\[a-zA-Z]/.test(s.slice(i + 1))) {
3919
+ // TeX reads `x^23` as `x^2` times `3`: the exponent is one token
3920
+ const m = /^(?:\\[a-zA-Z]+|[a-zA-Z0-9])/.exec(s.slice(i + 1)) as RegExpExecArray;
3921
+ i += 1 + m[0].length;
3922
+ out += `^(${transformLatex(m[0], opts)})`;
3779
3923
  continue;
3780
3924
  }
3781
3925
  if (ch === "^" && s[i + 1] === "{") {
3782
3926
  const { content, next } = extractGroup(s, i + 1);
3783
3927
  i = next;
3784
- out += `^(${transformLatex(content)})`;
3928
+ out += `^(${transformLatex(content, opts)})`;
3785
3929
  continue;
3786
3930
  }
3787
3931
  if (ch === "_" && s[i + 1] === "{") {
@@ -3793,7 +3937,7 @@ function transformLatex(s: string): string {
3793
3937
  if (ch === "{") {
3794
3938
  const { content, next } = extractGroup(s, i);
3795
3939
  i = next;
3796
- out += `(${transformLatex(content)})`;
3940
+ out += `(${transformLatex(content, opts)})`;
3797
3941
  continue;
3798
3942
  }
3799
3943
  if (ch === "=") {
@@ -3810,6 +3954,398 @@ function transformLatex(s: string): string {
3810
3954
  return out;
3811
3955
  }
3812
3956
 
3957
+ // -- rational-function normalization (expand + cancel) ----------------------------
3958
+ // `simplify` finishes by trying to read the expression as a quotient of two
3959
+ // polynomials (over the rationals, in "atoms": variables and any subexpression that
3960
+ // is not itself polynomial, such as `sin(x)`), expanding both, cancelling their
3961
+ // greatest common divisor, and printing the result back out. The result is used only
3962
+ // when it is strictly smaller than what `simplify` had already reached, so products
3963
+ // like `(x+1)*2` that are no smaller expanded keep their form.
3964
+
3965
+ /** A monomial's exponent per atom, and its coefficient. */
3966
+ interface PolyTerm {
3967
+ exps: number[];
3968
+ c: Rational;
3969
+ }
3970
+ type Poly = PolyTerm[]; // sorted by lex order descending, no zero coefficients, no duplicate monomials
3971
+
3972
+ const MAX_POLY_TERMS = 400;
3973
+ const MAX_RATIONAL_POWER = 8;
3974
+
3975
+ class RatBail extends Error {}
3976
+
3977
+ interface RatCtx {
3978
+ atoms: Expr[];
3979
+ keys: string[];
3980
+ }
3981
+
3982
+ function atomIndex(ctx: RatCtx, e: Expr): number {
3983
+ const key = render(e, 0);
3984
+ let i = ctx.keys.indexOf(key);
3985
+ if (i < 0) {
3986
+ i = ctx.keys.length;
3987
+ ctx.keys.push(key);
3988
+ ctx.atoms.push(e);
3989
+ }
3990
+ return i;
3991
+ }
3992
+
3993
+ function expsCmp(a: number[], b: number[]): number {
3994
+ const n = Math.max(a.length, b.length);
3995
+ for (let i = 0; i < n; i++) {
3996
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
3997
+ if (d !== 0) return d;
3998
+ }
3999
+ return 0;
4000
+ }
4001
+
4002
+ function expsKey(a: number[]): string {
4003
+ let end = a.length;
4004
+ while (end > 0 && !a[end - 1]) end--;
4005
+ return a.slice(0, end).join(",");
4006
+ }
4007
+
4008
+ function polyNormalize(terms: PolyTerm[]): Poly {
4009
+ const byKey = new Map<string, PolyTerm>();
4010
+ for (const t of terms) {
4011
+ const k = expsKey(t.exps);
4012
+ const prev = byKey.get(k);
4013
+ if (prev) byKey.set(k, { exps: prev.exps, c: prev.c.add(t.c) });
4014
+ else byKey.set(k, { exps: t.exps, c: t.c });
4015
+ }
4016
+ const out = [...byKey.values()].filter((t) => !t.c.isZero());
4017
+ out.sort((a, b) => expsCmp(b.exps, a.exps));
4018
+ if (out.length > MAX_POLY_TERMS) throw new RatBail();
4019
+ return out;
4020
+ }
4021
+
4022
+ const polyConst = (c: Rational): Poly => (c.isZero() ? [] : [{ exps: [], c }]);
4023
+ const polyAdd = (a: Poly, b: Poly): Poly => polyNormalize([...a, ...b]);
4024
+ const polyNeg = (a: Poly): Poly => a.map((t) => ({ exps: t.exps, c: t.c.negate() }));
4025
+ const polySub = (a: Poly, b: Poly): Poly => polyAdd(a, polyNeg(b));
4026
+
4027
+ function polyMul(a: Poly, b: Poly): Poly {
4028
+ if (a.length * b.length > MAX_POLY_TERMS * 20) throw new RatBail();
4029
+ const out: PolyTerm[] = [];
4030
+ for (const x of a) {
4031
+ for (const y of b) {
4032
+ const n = Math.max(x.exps.length, y.exps.length);
4033
+ const exps: number[] = [];
4034
+ for (let i = 0; i < n; i++) exps.push((x.exps[i] ?? 0) + (y.exps[i] ?? 0));
4035
+ out.push({ exps, c: x.c.multiply(y.c) });
4036
+ }
4037
+ }
4038
+ return polyNormalize(out);
4039
+ }
4040
+
4041
+ function polyEqual(a: Poly, b: Poly): boolean {
4042
+ return a.length === b.length && a.every((t, i) => expsKey(t.exps) === expsKey(b[i].exps) && t.c.equals(b[i].c));
4043
+ }
4044
+
4045
+ const polyIsConst = (a: Poly): boolean => a.length === 0 || (a.length === 1 && expsKey(a[0].exps) === "");
4046
+
4047
+ /** Division in lex order: `a = q*b + r` (`b` must be nonzero). */
4048
+ function polyDivMod(a: Poly, b: Poly): { q: Poly; r: Poly } {
4049
+ const lead = b[0];
4050
+ let rem = a;
4051
+ const q: PolyTerm[] = [];
4052
+ const r: PolyTerm[] = [];
4053
+ let guard = 0;
4054
+ while (rem.length > 0) {
4055
+ if (++guard > 20_000) throw new RatBail();
4056
+ const t = rem[0];
4057
+ const n = Math.max(t.exps.length, lead.exps.length);
4058
+ const exps: number[] = [];
4059
+ let divisible = true;
4060
+ for (let i = 0; i < n; i++) {
4061
+ const d = (t.exps[i] ?? 0) - (lead.exps[i] ?? 0);
4062
+ if (d < 0) divisible = false;
4063
+ exps.push(d);
4064
+ }
4065
+ if (!divisible) {
4066
+ r.push(t);
4067
+ rem = rem.slice(1);
4068
+ continue;
4069
+ }
4070
+ const qt: PolyTerm = { exps, c: t.c.divide(lead.c) };
4071
+ q.push(qt);
4072
+ rem = polySub(rem, polyMul([qt], b));
4073
+ }
4074
+ return { q: polyNormalize(q), r: polyNormalize(r) };
4075
+ }
4076
+
4077
+ function polyAtoms(...ps: Poly[]): Set<number> {
4078
+ const s = new Set<number>();
4079
+ for (const p of ps) {
4080
+ for (const t of p) {
4081
+ for (let i = 0; i < t.exps.length; i++) if (t.exps[i] > 0) s.add(i);
4082
+ }
4083
+ }
4084
+ return s;
4085
+ }
4086
+
4087
+ /** Monic GCD of two polynomials in one atom (Euclid); 1 when several atoms are involved. */
4088
+ function polyGcd(a: Poly, b: Poly): Poly {
4089
+ if (polyAtoms(a, b).size > 1) return polyConst(Rational.One);
4090
+ let x = a;
4091
+ let y = b;
4092
+ while (y.length > 0) {
4093
+ const { r } = polyDivMod(x, y);
4094
+ x = y;
4095
+ y = r;
4096
+ }
4097
+ if (x.length === 0) return polyConst(Rational.One);
4098
+ const lc = x[0].c;
4099
+ return x.map((t) => ({ exps: t.exps, c: t.c.divide(lc) }));
4100
+ }
4101
+
4102
+ /** The largest monomial dividing every term (exponent-wise minimum). */
4103
+ function monomialContent(p: Poly): number[] {
4104
+ if (p.length === 0) return [];
4105
+ const n = Math.max(...p.map((t) => t.exps.length));
4106
+ const out: number[] = [];
4107
+ for (let i = 0; i < n; i++) out.push(Math.min(...p.map((t) => t.exps[i] ?? 0)));
4108
+ return out;
4109
+ }
4110
+
4111
+ function polyDivMonomial(p: Poly, m: number[]): Poly {
4112
+ return p.map((t) => ({ exps: t.exps.map((e, i) => e - (m[i] ?? 0)), c: t.c }));
4113
+ }
4114
+
4115
+ interface Ratio {
4116
+ n: Poly;
4117
+ d: Poly;
4118
+ }
4119
+
4120
+ const ratioFromPoly = (n: Poly): Ratio => ({ n, d: polyConst(Rational.One) });
4121
+
4122
+ function ratioAdd(a: Ratio, b: Ratio, subtract: boolean): Ratio {
4123
+ const bn = subtract ? polyNeg(b.n) : b.n;
4124
+ if (polyEqual(a.d, b.d)) return { n: polyAdd(a.n, bn), d: a.d };
4125
+ return { n: polyAdd(polyMul(a.n, b.d), polyMul(bn, a.d)), d: polyMul(a.d, b.d) };
4126
+ }
4127
+
4128
+ const ratioMul = (a: Ratio, b: Ratio): Ratio => ({ n: polyMul(a.n, b.n), d: polyMul(a.d, b.d) });
4129
+
4130
+ function ratioDiv(a: Ratio, b: Ratio): Ratio {
4131
+ if (b.n.length === 0) throw new RatBail(); // division by zero: leave it to evaluation
4132
+ return { n: polyMul(a.n, b.d), d: polyMul(a.d, b.n) };
4133
+ }
4134
+
4135
+ function ratioPow(a: Ratio, n: number): Ratio {
4136
+ const k = Math.abs(n);
4137
+ if (k > 1000) throw new RatBail();
4138
+ const singleTerm = a.n.length === 1 && a.d.length === 1;
4139
+ if (!singleTerm && k > MAX_RATIONAL_POWER) throw new RatBail();
4140
+ let result: Ratio;
4141
+ if (singleTerm) {
4142
+ const mono = (t: PolyTerm): PolyTerm => ({ exps: t.exps.map((e) => e * k), c: t.c.pow(k) });
4143
+ result = { n: [mono(a.n[0])], d: [mono(a.d[0])] };
4144
+ } else {
4145
+ result = ratioFromPoly(polyConst(Rational.One));
4146
+ for (let i = 0; i < k; i++) result = ratioMul(result, a);
4147
+ }
4148
+ return n < 0 ? ratioDiv(ratioFromPoly(polyConst(Rational.One)), result) : result;
4149
+ }
4150
+
4151
+ function exactRational(value: number): Rational | null {
4152
+ if (!Number.isFinite(value)) return null;
4153
+ if (Number.isInteger(value)) return new Rational(BigInt(value));
4154
+ const r = Rational.fromNumber(value, 10_000);
4155
+ return r.toNumber() === value ? r : null;
4156
+ }
4157
+
4158
+ function toRatio(e: Expr, ctx: RatCtx): Ratio {
4159
+ const atom = (x: Expr): Ratio => {
4160
+ const i = atomIndex(ctx, x);
4161
+ const exps: number[] = new Array(i + 1).fill(0);
4162
+ exps[i] = 1;
4163
+ return ratioFromPoly([{ exps, c: Rational.One }]);
4164
+ };
4165
+ switch (e.type) {
4166
+ case "const": {
4167
+ const r = exactRational(e.value);
4168
+ return r ? ratioFromPoly(polyConst(r)) : atom(e);
4169
+ }
4170
+ case "var":
4171
+ return atom(e);
4172
+ case "neg": {
4173
+ const a = toRatio(e.arg, ctx);
4174
+ return { n: polyNeg(a.n), d: a.d };
4175
+ }
4176
+ case "add":
4177
+ return ratioAdd(toRatio(e.left, ctx), toRatio(e.right, ctx), false);
4178
+ case "sub":
4179
+ return ratioAdd(toRatio(e.left, ctx), toRatio(e.right, ctx), true);
4180
+ case "mul":
4181
+ return ratioMul(toRatio(e.left, ctx), toRatio(e.right, ctx));
4182
+ case "div":
4183
+ return ratioDiv(toRatio(e.left, ctx), toRatio(e.right, ctx));
4184
+ case "pow": {
4185
+ if (e.exp.type === "const" && Number.isInteger(e.exp.value)) {
4186
+ return ratioPow(toRatio(e.base, ctx), e.exp.value);
4187
+ }
4188
+ return atom(pow(normalizeRational(e.base), normalizeRational(e.exp)));
4189
+ }
4190
+ case "func":
4191
+ return atom(fn(e.name, normalizeRational(e.arg)));
4192
+ case "call2":
4193
+ return atom(call2(e.name, normalizeRational(e.left), normalizeRational(e.right)));
4194
+ case "cmp":
4195
+ return atom(cmp(e.op, normalizeRational(e.left), normalizeRational(e.right)));
4196
+ case "piecewise":
4197
+ return atom(
4198
+ piecewise(
4199
+ e.branches.map((br) => ({ cond: normalizeRational(br.cond), expr: normalizeRational(br.expr) })),
4200
+ normalizeRational(e.otherwise),
4201
+ ),
4202
+ );
4203
+ case "sum":
4204
+ return atom(sumExpr(e.variable, normalizeRational(e.from), normalizeRational(e.to), normalizeRational(e.body)));
4205
+ case "product":
4206
+ return atom(
4207
+ productExpr(e.variable, normalizeRational(e.from), normalizeRational(e.to), normalizeRational(e.body)),
4208
+ );
4209
+ }
4210
+ }
4211
+
4212
+ function bigGcd(a: bigint, b: bigint): bigint {
4213
+ let x = a < 0n ? -a : a;
4214
+ let y = b < 0n ? -b : b;
4215
+ while (y) [x, y] = [y, x % y];
4216
+ return x;
4217
+ }
4218
+
4219
+ /** Cancel common factors, then scale to coprime integer coefficients with a positive denominator lead. */
4220
+ function ratioReduce(r: Ratio): Ratio {
4221
+ if (r.n.length === 0) return ratioFromPoly([]);
4222
+ let { n, d } = r;
4223
+ const m = monomialContent([...n, ...d]);
4224
+ if (m.some((e) => e > 0)) {
4225
+ n = polyDivMonomial(n, m);
4226
+ d = polyDivMonomial(d, m);
4227
+ }
4228
+ if (!polyIsConst(d)) {
4229
+ const g = polyGcd(n, d);
4230
+ if (!polyIsConst(g)) {
4231
+ n = polyDivMod(n, g).q;
4232
+ d = polyDivMod(d, g).q;
4233
+ } else {
4234
+ // Several atoms: still cancel when one side divides the other exactly.
4235
+ const dn = polyDivMod(n, d);
4236
+ if (dn.r.length === 0) {
4237
+ n = dn.q;
4238
+ d = polyConst(Rational.One);
4239
+ } else {
4240
+ const nd = polyDivMod(d, n);
4241
+ if (nd.r.length === 0) {
4242
+ d = nd.q;
4243
+ n = polyConst(Rational.One);
4244
+ }
4245
+ }
4246
+ }
4247
+ }
4248
+ if (polyIsConst(d)) {
4249
+ const c = d[0].c;
4250
+ return { n: n.map((t) => ({ exps: t.exps, c: t.c.divide(c) })), d: polyConst(Rational.One) };
4251
+ }
4252
+ // integer coefficients: multiply through by the lcm of the denominators, divide by the content
4253
+ let lcm = 1n;
4254
+ for (const t of [...n, ...d]) {
4255
+ const den = t.c.denominator;
4256
+ lcm = (lcm / bigGcd(lcm, den)) * den;
4257
+ }
4258
+ const scale = new Rational(lcm);
4259
+ n = n.map((t) => ({ exps: t.exps, c: t.c.multiply(scale) }));
4260
+ d = d.map((t) => ({ exps: t.exps, c: t.c.multiply(scale) }));
4261
+ let content = 0n;
4262
+ for (const t of [...n, ...d]) content = bigGcd(content, t.c.numerator);
4263
+ if (content > 1n) {
4264
+ const inv = new Rational(1n, content);
4265
+ n = n.map((t) => ({ exps: t.exps, c: t.c.multiply(inv) }));
4266
+ d = d.map((t) => ({ exps: t.exps, c: t.c.multiply(inv) }));
4267
+ }
4268
+ if (d[0].c.sign < 0) {
4269
+ n = polyNeg(n);
4270
+ d = polyNeg(d);
4271
+ }
4272
+ return { n, d };
4273
+ }
4274
+
4275
+ function monomialExpr(exps: number[], ctx: RatCtx): Expr | null {
4276
+ let out: Expr | null = null;
4277
+ for (let i = 0; i < exps.length; i++) {
4278
+ const e = exps[i] ?? 0;
4279
+ if (e === 0) continue;
4280
+ const factor = e === 1 ? ctx.atoms[i] : pow(ctx.atoms[i], num(e));
4281
+ out = out ? mul(out, factor) : factor;
4282
+ }
4283
+ return out;
4284
+ }
4285
+
4286
+ /** A polynomial as a descending-degree sum; fractional coefficients print as `p*m/q`. */
4287
+ function polyToTree(p: Poly, ctx: RatCtx): Expr {
4288
+ if (p.length === 0) return num(0);
4289
+ const ordered = [...p].sort((a, b) => {
4290
+ const da = a.exps.reduce((s, x) => s + x, 0);
4291
+ const db = b.exps.reduce((s, x) => s + x, 0);
4292
+ return db - da || expsCmp(b.exps, a.exps);
4293
+ });
4294
+ let out: Expr | null = null;
4295
+ for (const t of ordered) {
4296
+ const negative = t.c.sign < 0;
4297
+ const abs = t.c.abs();
4298
+ const mono = monomialExpr(t.exps, ctx);
4299
+ const numer = abs.numerator;
4300
+ let body: Expr;
4301
+ if (!mono) body = num(abs.toNumber());
4302
+ else if (numer === 1n && abs.denominator === 1n) body = mono;
4303
+ else if (abs.denominator === 1n) body = mul(num(Number(numer)), mono);
4304
+ else body = div(numer === 1n ? mono : mul(num(Number(numer)), mono), num(Number(abs.denominator)));
4305
+ if (!out) out = negative ? neg(body) : body;
4306
+ else out = negative ? sub(out, body) : add(out, body);
4307
+ }
4308
+ return out as Expr;
4309
+ }
4310
+
4311
+ function nodeCount(e: Expr): number {
4312
+ switch (e.type) {
4313
+ case "const":
4314
+ case "var":
4315
+ return 1;
4316
+ case "neg":
4317
+ case "func":
4318
+ return 1 + nodeCount(e.arg);
4319
+ case "pow":
4320
+ return 1 + nodeCount(e.base) + nodeCount(e.exp);
4321
+ case "piecewise":
4322
+ return 1 + nodeCount(e.otherwise) + e.branches.reduce((s, br) => s + nodeCount(br.cond) + nodeCount(br.expr), 0);
4323
+ case "sum":
4324
+ case "product":
4325
+ return 1 + nodeCount(e.from) + nodeCount(e.to) + nodeCount(e.body);
4326
+ default:
4327
+ return 1 + nodeCount(e.left) + nodeCount(e.right);
4328
+ }
4329
+ }
4330
+
4331
+ /**
4332
+ * Expand `e` as a rational function and cancel common factors, returning the result only
4333
+ * when it has strictly fewer nodes than `e`; otherwise returns `e` itself.
4334
+ */
4335
+ function normalizeRational(e: Expr): Expr {
4336
+ if (e.type === "const" || e.type === "var") return e;
4337
+ try {
4338
+ const ctx: RatCtx = { atoms: [], keys: [] };
4339
+ const reduced = ratioReduce(toRatio(e, ctx));
4340
+ const numer = polyToTree(reduced.n, ctx);
4341
+ const candidate = polyIsConst(reduced.d) ? numer : div(numer, polyToTree(reduced.d, ctx));
4342
+ return nodeCount(candidate) < nodeCount(e) ? candidate : e;
4343
+ } catch (err) {
4344
+ if (err instanceof RatBail) return e;
4345
+ throw err;
4346
+ }
4347
+ }
4348
+
3813
4349
  // -- equation solving & factoring ----------------------------------------------
3814
4350
  /** Best-effort exact `Expr` for a numeric value: an integer, a reduced fraction, or (as a last resort) the raw float. */
3815
4351
  function toExactExpr(value: number): Expr {
@@ -3841,7 +4377,7 @@ function linearCoeffsForSystem(eq: Expr, variables: string[]): number[] | null {
3841
4377
  if (!Number.isFinite(constant)) return null;
3842
4378
  const coeffs: number[] = [];
3843
4379
  for (const name of variables) {
3844
- const derivative = Symbolic.simplify(diff(eq, name));
4380
+ const derivative = simplifyCore(diff(eq, name));
3845
4381
  if (variables.some((other) => containsVar(derivative, other))) return null;
3846
4382
  const coeff = evalExpr(derivative, zeroEnv);
3847
4383
  if (!Number.isFinite(coeff)) return null;
@@ -3870,7 +4406,7 @@ function polynomialCoeffs(expr: Expr, variable: string, maxDegree: number): numb
3870
4406
  // derivative of x^0 (`0·x^-1`) evaluates to `0·Infinity = NaN` at x=0
3871
4407
  // even though it is symbolically zero — simplifying collapses `0·anything`
3872
4408
  // to `0` before that indeterminate form can arise.
3873
- term = Symbolic.simplify(diff(term, variable));
4409
+ term = simplifyCore(diff(term, variable));
3874
4410
  }
3875
4411
  while (coeffs.length > 1 && Math.abs(coeffs[coeffs.length - 1]) < 1e-9) coeffs.pop();
3876
4412
  for (const p of [1.3, -2.1, 3.7]) {
@@ -4058,7 +4594,7 @@ function factorPolynomial(coeffsIn: number[], variable: string): Expr {
4058
4594
  } else if (Math.abs(remaining[0] - 1) > 1e-9) {
4059
4595
  result = mul(result, toExactExpr(remaining[0]));
4060
4596
  }
4061
- return Symbolic.simplify(result);
4597
+ return simplifyCore(result);
4062
4598
  }
4063
4599
 
4064
4600
  // -- limits ---------------------------------------------------------------------
@@ -4083,8 +4619,8 @@ function limitAt(e: Expr, x: string, point: number, direction: "left" | "right"
4083
4619
  // operand verbatim into the result) and a limit needing more than a
4084
4620
  // couple of rounds (e.g. most limits at infinity) blows up memory
4085
4621
  // 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));
4622
+ const df = simplifyCore(diff(e.left, x));
4623
+ const dg = simplifyCore(diff(e.right, x));
4088
4624
  return limitAt(div(df, dg), x, point, direction, depth + 1);
4089
4625
  }
4090
4626
  }
@@ -4092,12 +4628,19 @@ function limitAt(e: Expr, x: string, point: number, direction: "left" | "right"
4092
4628
  }
4093
4629
 
4094
4630
  // -- recursive-descent parser ----------------------------------------------
4631
+ /** Every name the parser treats as a function, longest first, so `asinh(` is not read as `a*sinh(`. */
4632
+ const FUNCTION_NAMES_LONGEST_FIRST: string[] = [...FUNCTION_NAMES].sort((a, b) => b.length - a.length);
4633
+
4095
4634
  class Parser {
4096
4635
  private readonly s: string;
4636
+ private readonly implicit: boolean;
4097
4637
  private pos = 0;
4098
4638
 
4099
- constructor(input: string) {
4100
- this.s = input.replace(/\s+/g, "");
4639
+ constructor(input: string, options: ParseOptions = {}) {
4640
+ this.implicit = options.implicitMultiplication === true;
4641
+ // With implicit multiplication, white space between two numbers is a product (`2 3`), not one number.
4642
+ const spaced = this.implicit ? input.replace(/(\d)\s+(?=[\d.])/g, "$1*") : input;
4643
+ this.s = spaced.replace(/\s+/g, "");
4101
4644
  }
4102
4645
 
4103
4646
  parse(): Expr {
@@ -4218,12 +4761,34 @@ class Parser {
4218
4761
 
4219
4762
  private term(): Expr {
4220
4763
  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);
4764
+ for (;;) {
4765
+ const ch = this.peek();
4766
+ if (ch === "*" || ch === "/") {
4767
+ this.pos++;
4768
+ const right = this.factor();
4769
+ left = ch === "*" ? mul(left, right) : div(left, right);
4770
+ } else if (this.implicit && /[a-zA-Z0-9.(]/.test(ch)) {
4771
+ left = mul(left, this.factor()); // juxtaposition: `2x`, `x y`, `2(x+1)`
4772
+ } else {
4773
+ return left;
4774
+ }
4225
4775
  }
4226
- return left;
4776
+ }
4777
+
4778
+ /**
4779
+ * The identifier at `this.pos` under implicit multiplication: a function name followed by `(`, `pi`, or a
4780
+ * single letter with an optional `_subscript`. A letter run like `xy` is therefore read one letter at a time.
4781
+ */
4782
+ private implicitIdentifier(): string {
4783
+ const rest = this.s.slice(this.pos);
4784
+ const run = /^[a-zA-Z]+/.exec(rest)?.[0] ?? "";
4785
+ const call = FUNCTION_NAMES_LONGEST_FIRST.find((f) => rest.startsWith(f) && rest[f.length] === "(");
4786
+ if (call) return call;
4787
+ const bare = FUNCTION_NAMES_LONGEST_FIRST.find((f) => f.length > 1 && run.startsWith(f));
4788
+ if (bare) throw new Error(`Function ${bare} needs parentheses: ${bare}(...)`);
4789
+ if (rest.startsWith("pi")) return "pi";
4790
+ const sub = /^[a-zA-Z]_(?:\d+|[a-zA-Z])/.exec(rest);
4791
+ return sub ? sub[0] : rest[0];
4227
4792
  }
4228
4793
 
4229
4794
  private factor(): Expr {
@@ -4261,9 +4826,11 @@ class Parser {
4261
4826
  return num(Number(numMatch[0]));
4262
4827
  }
4263
4828
  // identifier (function or variable or constant)
4264
- const idMatch = /^[a-zA-Z_]\w*/.exec(this.s.slice(this.pos));
4829
+ const idMatch = this.implicit
4830
+ ? /^[a-zA-Z_]/.exec(this.s.slice(this.pos))
4831
+ : /^[a-zA-Z_]\w*/.exec(this.s.slice(this.pos));
4265
4832
  if (idMatch) {
4266
- const name = idMatch[0];
4833
+ const name = this.implicit ? this.implicitIdentifier() : idMatch[0];
4267
4834
  this.pos += name.length;
4268
4835
  if ((name === "log" || name === "clamp") && this.peek() === "(") {
4269
4836
  this.pos++;