Equations & Complex Numbers
import it.grypho.scala.leonardo.core.*
import it.grypho.scala.leonardo.scalar.*
import it.grypho.scala.leonardo.equation.*
import it.grypho.scala.leonardo.parser.Parser
val x = _Variable("x")
val env = new Environment()
Equations
An _Equation(lhs, rhs) is a relation node. When both sides reduce to concrete values, eval returns a _Bool using tolerance-based equality (|a − b| ≤ 0.5 × 10⁻ᵖ at precision p), so floating-point noise does not produce false negatives:
// 2x = 6 at x = 3 → true
val eq1 = _Equation(Product(_Number(2.0), x), _Number(6.0))
// eq1: _Equation = _Equation(
// lhs = Product(a = _Number(d = 2.0), b = _Variable(variable = "x")),
// rhs = _Number(d = 6.0)
// )
val envX3 = new Environment(5, Map("x" -> _Number(3.0)))
// envX3: Environment = it.grypho.scala.leonardo.core.Environment@63849b17
eq1.eval(envX3)
// res0: Either[_Expression, _Value] = Right(value = _Bool(b = true))
// sin(π) = 0 — true despite floating-point noise
_Equation(Sin(_Number(math.Pi)), _Number(0.0)).eval(env)
// res1: Either[_Expression, _Value] = Right(value = _Bool(b = true))
Equations with free variables stay symbolic:
eq1.eval(env)
// res2: Either[_Expression, _Value] = Left(
// value = _Equation(
// lhs = Product(a = _Number(d = 2.0), b = _Variable(variable = "x")),
// rhs = _Number(d = 6.0)
// )
// )
Equality check (==)
_EqualityCheck has the same semantics as _Equation but is not accepted by solve. Use it when you want a boolean result without the intent to solve:
Parser.parse("x^2 == x * x").get.eval(envX3)
// res3: Either[_Expression, _Value] = Right(value = _Bool(b = true))
Solving equations
All solve examples use the solve(…, v) grammar, which builds a _Solve node evaluated by calling the underlying solve algorithm:
Linear equations
// 2x + 3 = 7 → x = 2
Parser.parse("solve(2*x + 3 = 7, x)").get.eval(env)
// res4: Either[_Expression, _Value] = Left(
// value = _Equation(lhs = _Variable(variable = "x"), rhs = _Number(d = 2.0))
// )
// Inline: a*x + b = 0 with concrete a, b
Parser.parse("solve(3*x - 6 = 0, x)").get.eval(env)
// res5: Either[_Expression, _Value] = Left(
// value = _Equation(lhs = _Variable(variable = "x"), rhs = _Number(d = 2.0))
// )
Quadratic equations
The discriminant decides the case; 0, 1, or 2 real roots are returned, computed by the numerically stable form of the quadratic formula rather than the textbook (-b ± √Δ)/2a:
// x² - 5x + 6 = 0 → x = 2, x = 3
Parser.parse("solve(x^2 - 5*x + 6 = 0, x)").get.eval(env)
// res6: Either[_Expression, _Value] = Left(
// value = _Matrix(
// rows = 1,
// cols = 2,
// elems = Vector(
// _Equation(lhs = _Variable(variable = "x"), rhs = _Number(d = 2.0)),
// _Equation(lhs = _Variable(variable = "x"), rhs = _Number(d = 3.0))
// )
// )
// )
// x² + 1 = 0 → no real roots
Parser.parse("solve(x^2 + 1 = 0, x)").get.eval(env)
// res7: Either[_Expression, _Value] = Left(
// value = _Solve(
// eq = _Equation(
// lhs = Sum(
// a = Power(base = _Variable(variable = "x"), exp = _Number(d = 2.0)),
// b = _Number(d = 1.0)
// ),
// rhs = _Number(d = 0.0)
// ),
// v = _Variable(variable = "x")
// )
// )
Numeric fallback
Transcendental and higher-degree equations fall back to a sign-change scan over [-100, 100] followed by the bisection method (up to 8 roots):
// sin(x) = 0.5 — first root in [-100, 100]
Parser.parse("solve(sin(x) = 0.5, x)").get.eval(env)
// res8: Either[_Expression, _Value] = Left(
// value = _Matrix(
// rows = 1,
// cols = 8,
// elems = Vector(
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -97.91297103688188)
// ),
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -93.7241808320955)
// ),
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -91.62978572970229)
// ),
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -87.4409955249159)
// ),
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -85.34660042252273)
// ),
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -81.15781021773631)
// ),
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -79.06341511534313)
// ),
// _Equation(
// lhs = _Variable(variable = "x"),
// rhs = _Number(d = -74.87462491055675)
// )
// )
// )
// )
Matrix equations (scalar unknown)
When both sides are matrices, solve finds a scalar unknown by decomposing the equation into its per-cell scalar equations and keeping the value that satisfies every cell (the intersection of the per-cell solution sets):
// each cell agrees on x = 3
Parser.parse("solve([[x, 2*x]] = [[3, 6]], x)").get.eval(env)
// res9: Either[_Expression, _Value] = Left(
// value = _Equation(lhs = _Variable(variable = "x"), rhs = _Number(d = 3.0))
// )
An inconsistent system — where different cells demand different values — has no solution, so the node stays symbolic:
// cell 1 wants x = 1, cell 2 wants x = 2 → no common root
Parser.parse("solve([[x, x]] = [[1, 2]], x)").get.eval(env)
// res10: Either[_Expression, _Value] = Left(
// value = _Solve(
// eq = _Equation(
// lhs = _Matrix(
// rows = 1,
// cols = 2,
// elems = Vector(_Variable(variable = "x"), _Variable(variable = "x"))
// ),
// rhs = _Matrix(
// rows = 1,
// cols = 2,
// elems = Vector(_Number(d = 1.0), _Number(d = 2.0))
// )
// ),
// v = _Variable(variable = "x")
// )
// )
Matrix equations (matrix unknown)
When the unknown is itself a matrix in a linear equation A·X = B (or X·A = B), solve returns the unique solution X = A⁻¹·B via the inverse kernel. The known matrices must be bound so the parser sees a plain product (a bare X is a scalar variable syntactically):
val A = _MatrixValue(2, 2, Array(2.0, 0.0, 0.0, 2.0))
// A: _MatrixValue = [[2.0, 0.0], [0.0, 2.0]]
val B = _MatrixValue(2, 2, Array(4.0, 6.0, 8.0, 10.0))
// B: _MatrixValue = [[4.0, 6.0], [8.0, 10.0]]
val mEnv = new Environment(5, Map("A" -> A, "B" -> B))
// mEnv: Environment = it.grypho.scala.leonardo.core.Environment@19dce3d0
// A·X = B → X = A⁻¹·B = [[2, 3], [4, 5]]
Parser.parse("solve(A * X = B, X)").get.eval(mEnv)
// res11: Either[_Expression, _Value] = Left(
// value = _Equation(
// lhs = _Variable(variable = "X"),
// rhs = [[2.0, 3.0], [4.0, 5.0]]
// )
// )
A singular (non-invertible) A has no unique solution and stays symbolic.
Beyond the one-sided shapes, solve also recognises an affine term and a two-sided product. A constant matrix added on the unknown’s side is peeled to the right (A·X + C = B → A·X = B − C), and a matrix on each flank is handled by inverting both (A·X·D = B → X = A⁻¹·B·D⁻¹):
val D = _MatrixValue(2, 2, Array(5.0, 0.0, 0.0, 5.0))
// D: _MatrixValue = [[5.0, 0.0], [0.0, 5.0]]
val B2 = _MatrixValue(2, 2, Array(10.0, 20.0, 30.0, 40.0))
// B2: _MatrixValue = [[10.0, 20.0], [30.0, 40.0]]
val twoSidedEnv = new Environment(5, Map("A" -> A, "D" -> D, "B" -> B2))
// twoSidedEnv: Environment = it.grypho.scala.leonardo.core.Environment@4651203a
// A·X·D = B → X = A⁻¹·B·D⁻¹ = [[1, 2], [3, 4]]
Parser.parse("solve(A * X * D = B, X)").get.eval(twoSidedEnv)
// res12: Either[_Expression, _Value] = Left(
// value = _Equation(
// lhs = _Variable(variable = "X"),
// rhs = [[1.0, 2.0], [3.0, 4.0]]
// )
// )
The coefficients may be symbolic _Matrix literals with free variables, in which case the solution is a symbolic matrix expression (via cofactor expansion, capped at 6×6).
General linear matrix equations — the unknown appearing in several terms with coefficients on different sides — are solved by Kronecker vectorization: each term s·L·X·R contributes s·(Rᵀ ⊗ L) to a dense system over vec(X). This covers the Sylvester equation A·X + X·B = C, the Lyapunov equation A·X + X·Aᵀ = C, and scalar coefficients like 2·X = B:
// Sylvester: A·X + X·B = C with X = [[1, 0], [2, 1]] → C = [[9, 3], [14, 10]]
val sA = _MatrixValue(2, 2, Array(1.0, 2.0, 0.0, 3.0))
// sA: _MatrixValue = [[1.0, 2.0], [0.0, 3.0]]
val sB = _MatrixValue(2, 2, Array(4.0, 1.0, 0.0, 5.0))
// sB: _MatrixValue = [[4.0, 1.0], [0.0, 5.0]]
val sC = _MatrixValue(2, 2, Array(9.0, 3.0, 14.0, 10.0))
// sC: _MatrixValue = [[9.0, 3.0], [14.0, 10.0]]
val sylvesterEnv = new Environment(5, Map("A" -> sA, "B" -> sB, "C" -> sC))
// sylvesterEnv: Environment = it.grypho.scala.leonardo.core.Environment@22a8716a
Parser.parse("solve(A * X + X * B = C, X)").get.eval(sylvesterEnv)
// res13: Either[_Expression, _Value] = Left(
// value = _Equation(
// lhs = _Variable(variable = "X"),
// rhs = [[1.0, 0.0], [2.0, 1.0]]
// )
// )
The system is solvable only when it is non-singular — for Sylvester, when A and −B share no eigenvalue; otherwise the equation has no (unique) solution and the solve node stays symbolic. Coefficients must reduce to dense matrices for this tier.
Linear systems
solveSystem([[eq₁, eq₂, …]], v₁, v₂, …) solves a square system via Gaussian elimination (partial pivoting for concrete coefficients, symbolic row reduction otherwise):
// x + y = 3, x - y = 1 → x = 2, y = 1
Parser.parse("solveSystem([[x + y = 3, x - y = 1]], x, y)").get.eval(env)
// res14: Either[_Expression, _Value] = Left(
// value = _Matrix(
// rows = 1,
// cols = 2,
// elems = Vector(
// _Equation(lhs = _Variable(variable = "x"), rhs = _Number(d = 2.0)),
// _Equation(lhs = _Variable(variable = "y"), rhs = _Number(d = 1.0))
// )
// )
// )
Complex numbers
The imaginary unit i is a built-in constant (_Complex(0, 1)). Arithmetic closes over the complex field:
val i = _Complex.of(0, 1)
// i² = -1
Product(i, i).eval(env)
// res15: Either[_Expression, _Value] = Right(value = _Number(d = -1.0))
// (2 + 3i)(1 - i) = 5 + i
Product(_Complex.of(2, 3), _Complex.of(1, -1)).eval(env)
// res16: Either[_Expression, _Value] = Right(
// value = _Complex(re = 5.0, im = 1.0)
// )
Euler’s identity
// e^(iπ) = -1
Exp(Product(i, _Number(math.Pi))).eval(env)
// res17: Either[_Expression, _Value] = Right(
// value = _Complex(re = -1.0, im = 1.2246467991473532E-16)
// )
Principal complex roots and logarithms
Negative bases with fractional exponents and logarithms of negative reals return their principal values rather than staying symbolic:
// √(-2) = i√2
Power(_Number(-2.0), _Number(0.5)).eval(env)
// res18: Either[_Expression, _Value] = Right(
// value = _Complex(re = 8.659560562354932E-17, im = 1.414213562373095)
// )
// ln(-1) = iπ
Ln(_Number(-1.0)).eval(env)
// res19: Either[_Expression, _Value] = Right(
// value = _Complex(re = 0.0, im = 3.141592653589793)
// )
Parsing complex literals
3i and 2 + 3i parse directly:
Parser.parse("2 + 3i").get.eval(env)
// res20: Either[_Expression, _Value] = Right(
// value = _Complex(re = 2.0, im = 3.0)
// )
Parser.parse("(1 + i)^4").get.eval(env)
// res21: Either[_Expression, _Value] = Right(
// value = _Complex(re = -4.000000000000001, im = 4.898587196589414E-16)
// )