Architecture
Leonardo is structured as a layered set of packages. Dependencies point inward: every domain package imports core (and usually scalar); nothing imports cli or parser; the graph is a strict DAG.
cli
│
parser
┌────────┬─────────┬──────┼──────┬─────────┬─────────┬────────┐
matrix equation transform ode logic probability domain vector … control
└────────┴─────────┴──────┴──────┴─────────┴─────────┴────────┘
scalar
│
core
The picture is a simplification — some domains also import each other in one direction (equation imports logic, statistics imports probability and matrix, control imports transform and matrix) — but the invariants hold everywhere: core imports no domain, every arrow is one-way, and parser and cli sit above all of them.
Modules
That layering is also the artifact boundary. Because nothing imports cli, it can be published separately — and it is, so that a library consumer never resolves JLine:
| Artifact | Contains | Extra dependencies |
|---|---|---|
it.grypho:leonardo | Every package except cli | scala-parser-combinators, spire |
it.grypho:leonardo-repl | cli only; depends on leonardo | jline |
The split cost no code change, which is the practical dividend of the layering rule: cli being a leaf that nothing imports is exactly what made it detachable. It also left the library free of any terminal dependency — the one thing that would have blocked a Scala.js cross-build.
Running off the JVM
Both modules cross-build for Scala.js, and the whole suite runs on Node as well as the JVM: 1781 library cases on each, plus the REPL’s. The port needed two platform-specific pieces in the library and four in the REPL; everything else is the same source.
| Platform-specific | Why |
|---|---|
| Parallel block dispatch | java.util.stream has no Scala.js counterpart, and a browser is single-threaded anyway |
Double rendering | Scala.js renders a Double the JavaScript way — 1 for 1.0, 0.00003 for 3.0E-5 |
| JLine read loop, highlighter | a terminal line editor has no meaning in a browser |
:save / :load storage | files on the JVM, localStorage in a browser — the same commands either way |
The interesting part is what did not need changing. Session — the entire REPL behaviour, including command parsing, display and session scripts — is untouched, because Session.execute, Session.script and Session.load were already pure string in / string out. The file system was the only JVM-bound part of :save, and it never lived inside Session.
Nothing Scala.js is published yet: the cross-build exists to prove the library runs in a browser and to keep that true, since CI runs the JavaScript suite on every push.
The browser front end
A third project, web, is the page itself. It is JS-only, publishes nothing, and is kept out of the root aggregate so that linking a bundle is never in the path of an ordinary library change; CI names it explicitly instead.
It is almost entirely not there, which is the point. Session.step already dispatches every command on both platforms, so the page is a text box, a transcript and a history ring. Only two things are genuinely browser-only: the plot / points commands, which a terminal cannot honour, and the shareable link.
| Weight over the wire | |
|---|---|
| Leonardo, the whole CAS | 3.46 MB raw, 475 KB gzipped |
Plotly (cartesian distribution, vendored) | 1.3 MB raw, 436 KB gzipped |
Under a megabyte for a computer algebra system with interactive plotting, which is what removed the one real risk in the plan: no module splitting and no lazy loading is needed.
Plotting adopts no Scala dependency. Plotly’s input is plain JSON, so the facade is a string builder — pure, and therefore unit-tested without a browser. The one line of it that chose Plotly over Vega-Lite is scaleanchor: a Nyquist diagram, a pole-zero map or a two-element vector drawn as a point is geometry, and on unequal axes the unit circle becomes an ellipse. Vega-Lite has no aspect lock, and deriving one from the data range stops holding the moment the reader zooms — a confidently wrong picture rather than a refusal.
Nothing the user types leaves the tab: the CAS is in the bundle, sessions live in localStorage, and a shared link carries its session in the URL fragment, which browsers never transmit.
The diagram below is generated automatically from docs/structure.puml by running sbt puml (or sbt site which runs the full pipeline).
Class and package diagram
Drag to pan · scroll, double-click, or use the corner controls to zoom · open the diagram in its own tab
Package guide
| Package | Role | Documentation |
|---|---|---|
core | _Expression trait, _Value marker, _Number, _Bool, _Complex, _Rational, _Truth, _Based, _Variable, _MatrixValue, Environment — the foundation shared by every domain | Expressions & Evaluation |
scalar | AST nodes (Sum, Product, Power, functions, functionals) and all algorithms: derive, integrate, simplify, expand, normalize, compile, sample, series expansions, domain analysis, plus a data-driven rewrite-rule engine (Rewrite) backing the parameterised table of integrals (IntegralRules) | Expressions & Evaluation · Calculus |
matrix | _Matrix symbolic node + _MatrixOperation nodes, constructors, the decompositions and expm; dense _MatrixValue kernels live in core | Matrices |
equation | _Equation, _Comparison, _EqualityCheck relation nodes; solve (equations, inequalities, matrix unknowns) and solveSystem | Equations & Complex Numbers |
transform | Laplace, Fourier, inverse Laplace, and the one-sided z-transform and its inverse | Features |
ode | _ODE node; closed-form linear and separable tiers, Runge–Kutta fallback | Features |
logic | The five connectives over one Kleene/fuzzy rule table; simplification, CNF/DNF, truth tables, membership curves, defuzzification | Logic |
probability | Distributions as first-class values; pdf/cdf/prob/quantile; expect/variance by a linearity rule table | Features |
statistics | Descriptive statistics, regression by QR, elementary inference (ttest, confint, chisqtest) | Features |
domain | Renders the neutral domain analysis (domain, differentiable, singularities) into relation nodes | Features |
vector | grad/div/curl/laplacian/jacobian/hessian over an ordered coordinate tuple, in three coordinate systems | Calculus |
control | Transfer-function algebra, stability, time and frequency response, state space, discretisation | Control Systems |
parser | Recursive-descent Parser (extends JavaTokenParsers); produces all AST node types; ReservedWords guard | Getting Started |
cli | Interactive Session (pure, IO-free core) + repl read loop; session scripts (:save/:load). Ships as the separate leonardo-repl artifact | Interactive REPL |
Key design decisions
Dual eval model — eval(env): Either[_Expression, _Value] is the single reduction point. Right means fully concrete; Left means one or more variables remain free. Every node type implements it; no special dispatch needed.
_ElementWise marker — nodes that are plain containers (matrix literals, matrix sums, transposes, equations) implement this trait. Algorithms such as derive, simplify, and expand use children/rebuild to distribute over them without domain-specific cases in the algorithm code.
Memoization — derive and simplify cache results in a bounded ConcurrentHashMap (see Memo.scala). This is the biggest win for simplifyFully’s fixpoint loop and for _DefIntegral’s Simpson integration, which re-derives the same subexpressions hundreds of times per evaluation.
compile fast path — compile(e, v, env): Option[Double ⇒ Double] translates an expression into a JVM closure when all nodes are compilable. Used by _DefIntegral (Simpson’s rule) and sample; eliminates per-step AST allocation and environment lookup.
A new carrier type must earn its place — a domain gets a _Value or a wrapper of its own only when something must read a value as one. A vector field is an n×1 _Matrix; a transfer function is an ordinary Ratio; a state-space model is the same 1×4 row of matrices lu and qr already return; an inequality’s solution set is a _Comparison. The reason is that a carrier is a wall: simplify, derive, substitute, the exact tier and the parser all operate on expressions, so a type outside that set would have to be taught to each of them or be cut off from all of them. The price is paid in ergonomics — with no type to dispatch on, control must take its frequency variable as an explicit argument everywhere — and in documentation, which is why Control Systems opens by saying so.