Compute — a CAS at run time
Every file that sees laws is a computer algebra system your program can call, over exactly the laws it opens.
laws.md is about the build: what the compiler folds and rewrites while it turns .hb into C#.
This page is about afterwards — a running program that wants to simplify, differentiate, prove or
solve an expression it only has as a string, using the same laws.
A CAS per file
-- Maths.hb
open Prelude.Calculus
model Maths {
law cancel = u − u = 0
}
A file whose scope sees at least one law gets a static member — in its own generated class, or, on a
model path, in a <File>.Compute.g.cs class beside the models (the one exception, HB0002, is under
Build-time checks below):
public static MLambda.Hilbert.Algebra.Compute Compute { get; }
The member carries the laws as .hb text and parses them lazily, once — on the first read of
Compute, never in the class’s static initialiser — so calling one of the file’s formulas costs no
parse at all.
Maths.Compute fires only the laws Maths.hb declares or opens — nothing another file of the same
project happens to open, and nothing invented. Precisely:
R(F) = own(F) ∪ ⋃_{o ∈ opens(F)} R(o)
Laws are document-wide at build time (laws.md); Compute is per-file — A.hb that opens only
Prelude.Arithmetic gets a Compute that cannot resolve a derivative, even sitting beside a B.hb
that opens Prelude.Calculus in the same project. The input to every operation is expressions
only — text, not a model or a fn.
Maths.Compute.Derivate("sin(x²)", "x"); // Resolved(cos(x²) · 2 · x, steps)
A file with no laws in scope gets no Compute member at all.
Build-time checks
Compute is a reserved name: once a file sees a law, its generated class already carries a
Compute member, so a fn or def whose C# method name would be Compute is a build error — a
method name capitalises only the first letter of the .hb name, so compute collides while cOMPUTE
(method COMPUTE) does not — and a whole file named Compute.hb is refused the same way — the check is a name
reservation only, not a recursion analysis, because Compute never reads a fn/def body. On a
model path (a library of frame/process/learner/trained models) the CAS instead lives beside
the models as <File>.Compute.g.cs, unless one of the file’s own models already claims the file’s
class name — then no Compute is emitted, and the build prints HB0002 as a warning, not a fault.
Two different files whose generated classes would collide on one name in one namespace are refused
at build with a diagnostic naming both files.
The laws text a generated Compute will parse is parsed at build time first. It is printed back from
the syntax tree, so a spelling the printer writes and the parser refuses is a build error naming the
file — never an exception the first time a running program reads Compute. A term law and an equation
law that share a name are refused at build too (and by Compute.Of), since a rw step naming it could
mean either.
Operations
Expressions are Hilbert text; results print back through the same printer as .hb source
(ToString()), and steps is the ordered list of law names that fired.
| Operation | Input | Result |
|---|---|---|
Simplify(e) |
expression | Rewritten(value, steps) |
Derivate(e, x) |
expression, variable | Resolved(value, steps) | Unresolved(residue, steps) — a ∂ was left |
Integrate(e, x) |
expression, variable | Resolved | Unresolved — an ∫ was left |
Substitute(e, x, v) |
expression, variable, expression | Rewritten — free x replaced by v, then simplified |
Function(λ) |
lambda | a compiled Function delegate |
Evaluate(λ, args…) |
lambda, values | Value = Function(λ)(args…) |
Proof(t) |
theorem with by |
Checked(steps) | Failed(step, left, right, reason) |
Demonstrate(t) |
theorem without by |
Demonstrated(steps, theorem) | NotFound(closest) |
Equivalent(a, b) |
two expressions | bool — Demonstrate("theorem _ : a = b") found a proof |
Solve(eq, x) |
equation, variable | Solved(x, value) | Unsolved(closest) |
Maths.Compute.Simplify("x · 1 + 0"); // Rewritten(x, [unit, zero])
Maths.Compute.Derivate("x²", "x"); // Resolved(2·x, [power, self, unit, power_one])
Maths.Compute.Substitute("x · 1", "x", "a + 1"); // Rewritten(a + 1, …)
Derivate builds ∂ e / ∂ x and rewrites it; Integrate builds ∫ e d x. You can also hand a
∂ u / ∂ x or ∫ u d x term straight to Simplify. Substitute refuses with
ComputeSyntaxException when replacing a name would let a free variable of the replacement be
captured by a lambda’s own parameter — Substitute("(y) ↦ x + y", "x", "y") is refused; substituting
z for x there is fine. A ∂ … / ∂ y or ∫ … d y binds y the same way, so
Substitute("∂ (x · y) / ∂ y", "x", "y") is refused as well. The variable of Derivate, Integrate,
Substitute and Solve must be a variable: π, e, or a nullary def the scope knows is a constant,
and naming one is a ComputeSyntaxException.
Every result prints as .hb text (ToString(), and so $"{result}"): Rewritten, Resolved and
Unresolved print their term, Demonstrated its theorem, Solved x = t, Failed the step, the reason
and both sides, NotFound/Unsolved the closest goal as left = right, and Checked its steps as
[unit, zero].
Proof and Demonstrate
A theorem with steps is checked; a theorem without steps is searched for, and what the search finds is written back as steps you can check:
Maths.Compute.Proof("theorem t : x · 1 + 0 = x by rw [unit, zero]"); // Checked([unit, zero])
Maths.Compute.Demonstrate("theorem t : x · 1 + 0 = x"); // Demonstrated([unit, zero], "…by rw […]")
Two tactics, both accepted by Proof:
by rewrite [a, b]— both sides are normalised with the listed laws and compared for equality.by rw [a, ← b, c]— replay. Each step rewrites the first matching instance of one law’s pattern, on both sides, in order; the proof isCheckedonly once the sides meet after the last step.
Demonstrate always emits rw steps, so Proof(Demonstrate(t).Theorem) is Checked by
construction whenever Demonstrate finds a proof — that is the round-trip property this module’s
own test suite holds itself to (ComputeCase.Every_corpus_theorem_the_search_demonstrates_is_checked_by_proof,
and DemonstrateCase). The theorem written back keeps the claim’s name and its ∀ binder. A claim
whose folded sides are already one term (x = x, 1 + 1 = 2) needs no step: Demonstrate writes it
back with no by, and Proof answers Checked([]) for a stepless claim that is already closed (and
Failed for one that is not).
Both tactics fold before they match — the claim’s sides and every law’s sides, the same way at build
time as in Proof — so y^(0 − 1) meets a law about x^(−1) as y^-1 against x^-1 in both, and a
theorem the build establishes is one Proof checks over the same laws.
A theorem is a rule of the scope only once it is established there. A Compute parses its file’s laws
and theorems together, and a theorem whose steps name a law outside that file’s scope — one another file
opened — does not check there, so it is not a rule of that Compute even when the library’s build
accepted it.
A step can run a law backwards (← name) only when doing so invents nothing: every variable of the
law’s left side must already be bound by its right side, vars(l) ⊆ vars(r). law nil = x · 0 = 0
would have to invent an x read right to left, so it is never reversed — Demonstrate never offers
← nil, and Proof refuses it if you write it by hand.
Solve and equation laws
Solve searches for x = t with x not free in t. Ordinary term laws move sub-terms; only an
equation law, written with ⇔, can move a whole equation — so without one, Solve cannot
isolate a variable at all:
law sub_add = a + b = c ⇔ a = c − b
law div_mul = a · b = c ⇔ b = c / a
compute.Solve("2 · x + 3 = 7", "x"); // Unsolved(closest) — no transposition law is open
// with sub_add and div_mul open:
compute.Solve("2 · x + 3 = 7", "x"); // Solved("x", 2)
Functions
A lambda simplifies under the file’s scope, then compiles once to a delegate:
public delegate Value Function(Value x, params Value[] rest);
var f = Maths.Compute.Function("x ↦ x · 1");
f(1); // 1
f([1, 2, 3]); // [1, 2, 3] — one Value, a vector, not three arguments
var g = Maths.Compute.Function("(x, y) ↦ x + y");
g(1, 2); // 3
Value is a scalar or a tensor, implicit from double, int and double[]
(Value.Of(double[,]) for rank 2). Calling a compiled function with the wrong number of arguments
throws ArgumentException naming both counts. Scalar intrinsics (sin, exp, ln, …) lower to
System.Math; a name Function does not know, or a ∂/∫ the file’s laws could not remove, is a
ComputeSyntaxException raised when you call Function, not later when you call the delegate. A
reduction such as minimum/maximum is not elementwise and has no value inside a compiled
function, so it is refused the same way. A lambda that repeats a parameter name ((x, x) ↦ …), names
no parameter (() ↦ 1), or names a parameter like a constant (e ↦ e + 1, or a nullary def the
scope knows — every use would mean the constant) is refused too.
What a body lowers to is a deliberate subset of the language, checked when Function is called:
- numbers, the lambda’s parameters, and the constants
πande; - unary
−and the binary+ − · / ^; - the elementwise intrinsics
sin cos tan exp ln sqrt abs(and|x|, which isabs),floor ceil round sign,atan asin acos,sinh cosh tanh, andmin/maxof two arguments.
Anything else is refused with ComputeSyntaxException — a tensor literal ([x, 1]), a comparison
(x < 1, x = 1), a reduction or shape query (minimum, rows, …), a call no intrinsic answers.
Integration prelude
Prelude.Integration is opt-in (open Prelude.Integration) and states antiderivatives as laws —
constant, sum, constant factor, sin, cos, exp, and power. A guard cannot say n ≠ −1, so the
prelude declares the reciprocal law before the power law and relies on first-match-wins against the
literal −1:
law int_reciprocal = ∫ (x^(−1)) d x = ln(x)
law int_power = ∫ (x^n) d x = x^(n + 1) / (n + 1) where x ∉ n
A symbolic n that turns out to be −1 only at run time is not caught — x^(n+1)/(n+1) divides by
zero — and int_reciprocal’s ln(x) holds only for x > 0. Both are stated limitations of this
prelude, not bugs: a rewrite rule cannot check a run-time sign.
Limits
A search (Demonstrate, Solve) is bounded, so it always terminates: depth ≤ 8, ≤ 10,000 goals
visited, and a term may not grow past 2 · max(|l|, |r|) + 8. On a cap, NotFound/Unsolved carries
the explored goal that came closest (smallest first, ties broken by depth). A Compute built by
Of uses the defaults; WithLimits returns a copy searched within limits you choose:
var patient = Maths.Compute.WithLimits(new MLambda.Hilbert.Algebra.Limits(Depth: 12));