The full workflow
This page chains LawSynth's pipeline on the shipped datasets:
discover → simplify → stability → control → export → validate → uncertainty
Every command and output below is from the current build. Reproduce it all with examples/run_all.sh. Start from a discovered world (see getting-started.md):
lawsynth discover lotka-volterra.csv \
--time time --state x,y --preset ecology \
--output lotka-volterra.lsworld
1. Simplify — reduce each law to its smallest equivalent form
simplify runs each law expression through the real e-graph equality-saturation engine (safe algebraic identities plus canonical ordering) and extracts the lowest-cost equivalent form. It then proves equivalence honestly by simulating both worlds and reporting the maximum trajectory deviation.
lawsynth simplify lotka-volterra.lsworld
Simplifying lotka-volterra.lsworld
Law dx/dt
before: dx/dt = 1.09937 * x + -0.399798 * x * y (9 nodes)
after: dx/dt = x * y * -0.399798 + 1.09937 * x (9 nodes)
already minimal under the safe rewrite rules
Law dy/dt
before: dy/dt = -0.399876 * y + 0.09999 * x * y (9 nodes)
after: dy/dt = x * y * 0.09999 + -0.399876 * y (9 nodes)
already minimal under the safe rewrite rules
Complexity: 18 -> 18 node(s) (0/2 law(s) reduced, 0 node(s) / 0.0% saved)
Equivalence: simulated both worlds (t in [0, 1], step 0.01, 2 state(s), 101 sample(s))
max trajectory deviation: 0.000e0 (tolerance 1e-9) -> EQUIVALENT
This is honest behaviour worth calling out: a freshly discovered Lotka–Volterra law is already minimal, so simplify reports 0 nodes saved. The rewrites still reorder terms into canonical form (note x * y * -0.399798 + 1.09937 * x), and the max trajectory deviation of exactly 0.000e0 confirms the reordered world is identical. Simplification pays off on redundant expressions (e.g. 1 * (-1 * x)), not on laws that are already tight. Add --output FILE to write the simplified-but-equivalent world. Contract: egraph-simplification and structural-reductions.
2. Stability — fixed points and linear classification
stability reads the world as an autonomous vector field, searches a caller-provided box for roots f(x)=0, and classifies each by its Jacobian eigenvalues. The search box is required and load-bearing: it fixes where the Newton seeds start and which roots are reported. The command always states how many seeds converged, so an empty result reads honestly as "nothing found in this box".
lawsynth stability lotka-volterra.lsworld --box 0:10,0:10
Stability analysis of lotka-volterra.lsworld
states: x, y
seeds: 25 tried, 25 converged
Fixed point(s): 2
#1 (x=0, y=0)
classification: saddle
eigenvalues: 1.09937 + 0i, -0.399876 + 0i
#2 (x=3.999172, y=2.749816)
classification: center (marginal, inconclusive)
eigenvalues: 0 + 0.663032i, 0 - 0.663032i
note: non-hyperbolic — linear stability is inconclusive here.
Both textbook Lotka–Volterra equilibria are recovered: the origin is a saddle, and the coexistence point is a center — purely imaginary eigenvalues that the linearization *cannot* decide, which the tool reports as inconclusive rather than guessing. One LOW:HIGH interval is required per state, in state order. Add --json for a machine-readable report. See stability-analysis and analytic-jacobian.
3. Control — discover a *forced* system (SINDYc)
Ordinary discover fits an autonomous field ẋ = f(x). Many real systems are *forced*: ẋ = f(x, u) with exogenous, measured control inputs u(t). The control command designates which columns are states and which are controls, and fits per-state equations over the augmented library Θ(x, u).
This needs a dataset with control columns. The shipped examples/forced-oscillator.csv is a deterministic driven damped oscillator (dx/dt = v, dv/dt = −x − 0.3·v + u, with u(t) = sin(0.7·t)), generated by examples/gen_forced_oscillator.py — pure math, RK4, no RNG.
lawsynth control forced-oscillator.csv \
--time time --state x,v --control u \
--degree 2 --threshold 0.05 --validate
Controlled discovery from forced-oscillator.csv
states: x, v
controls: u
samples: 601
library: 10 augmented term(s)
Discovered model dx/dt = f(x, u):
d/dt x = 0.999945*v
active terms: 1, residual SS: 0.000242
d/dt v = 0.999837*u + -0.29994*v + -0.999898*x
active terms: 3, residual SS: 0.000102
In-sample validation (rollout vs. observed states):
x R2=1 RMSE=0.000195
v R2=1 RMSE=0.000204
aggregate R2=1 RMSE=0.000199
note: in-sample (same data fitted); open-loop rollout error grows with horizon.
The forced law is recovered cleanly (dv/dt = u − 0.3·v − x). Two honest boundaries: control columns enter the library but are never differentiated and never predicted, and --validate reports an in-sample score (the same data was used to fit), which the output labels explicitly. A controlled model is a distinct type that does not serialize to a .lsworld bundle — that is why it is a separate command, not a discover flag. See controlled-discovery and control-design.
4. Export — run your model anywhere
export turns a .lsworld into a standalone, dependency-free artifact. Available formats: python, c, onnx (a labeled computation-graph JSON, not a binary .onnx), matlab, latex, json.
LaTeX, straight to stdout:
lawsynth export lotka-volterra.lsworld --format latex
% Law system for 'lotka-volterra', generated by LawSynth.
\begin{align*}
\dot{x} &= 1.09937 \cdot x + -0.399798 \cdot x \cdot y \\
\dot{y} &= -0.399876 \cdot y + 0.09999 \cdot x \cdot y
\end{align*}
A runnable, dependency-free Python module (with derivatives(...) and an RK4 simulate(...)):
lawsynth export lotka-volterra.lsworld --format python --output lv_model.py
wrote lv_model.py (1929 bytes)
The generated module's core:
STATE_VARS = ["x", "y"]
def derivatives(t, state, params):
"""Return d(state)/dt as a dict keyed by state name."""
return {
"x": 1.0993702168620976 * state["x"] + -0.3997977075034429 * state["x"] * state["y"],
"y": -0.39987602711843234 * state["y"] + 0.0999896968135492 * state["x"] * state["y"],
}
Omit --output to print to stdout.
5. Validate — score the world against held-out data
validate splits the dataset chronologically, simulates from the split point, and scores the model against the holdout with a persistence-baseline skill score.
lawsynth validate lotka-volterra.lsworld \
--data lotka-volterra.csv --time time --holdout 0.2
Validation: lotka-volterra.lsworld on lotka-volterra.csv
split at t=8 | train=160 rows | holdout=40 rows (fraction 0.20)
state RMSE MAE R2 skill_vs_persist
x 4.3303e-3 3.6253e-3 1.0000 0.9992
y 5.5707e-4 3.3659e-4 1.0000 0.9991
aggregate R2=1.0000 skill=0.9991
Verdict: STRONG - the model tracks held-out data closely
6. Uncertainty — forecast with confidence bands
forecast --confidence rolls the world forward and attaches a band derived from a residual bootstrap against a reference dataset. The bootstrap is seeded (--seed), so the band is reproducible.
lawsynth forecast lotka-volterra.lsworld \
--horizon 5 --start 0 --step 1 --initial x=10 --initial y=5 \
--confidence --data lotka-volterra.csv --time time \
--level 0.9 --replicates 100 --seed 7
time,x_lower,x_median,x_upper,y_lower,y_median,y_upper
0.000000000000e0,9.998823820784e0,1.000000000000e1,1.002660071866e1,4.998345674817e0,5.000000000000e0,5.006061891999e0
1.000000000000e0,2.825877873775e0,2.827054052990e0,2.853654771651e0,6.123737250284e0,6.125391575467e0,6.131453467466e0
2.000000000000e0,9.736156241624e-1,9.747918033782e-1,1.001392522039e0,4.812121246171e0,4.813775571354e0,4.819837463353e0
3.000000000000e0,5.616421231728e-1,5.628183023886e-1,5.894190210494e-1,3.464269976300e0,3.465924301483e0,3.471986193482e0
4.000000000000e0,5.219885803702e-1,5.231647595860e-1,5.497654782468e-1,2.447093241743e0,2.448747566926e0,2.454809458925e0
5.000000000000e0,6.843942159445e-1,6.855703951603e-1,7.121711138212e-1,1.739266122424e0,1.740920447608e0,1.746982339606e0
confidence forecast t in [0, 5], 6 samples, 90% band from residual bootstrap (100 replicates, seed 0x7)
state residuals offset_lower offset_upper offset_se
x 200 -1.176e-3 2.660e-2 8.741e-5
y 200 -1.654e-3 6.062e-3 1.260e-4
The output is a CSV with _lower/_median/_upper columns per state, plus a per-state summary of the residual band. Contracts: uncertainty-contract and coefficient-uncertainty.
Scope note on coefficient uncertainty:
discover --bootstrap Nruns a coefficient bootstrap during discovery, but the CLI's discovery summary does not print per-coefficient intervals — that structure is exposed through the library/API and thecoefficient-uncertaintyspec. The CLI's surfaced uncertainty path isforecast --confidence, shown above.
Related capabilities
Some analyses in the repository's specs/ are library crates, not (yet) CLI subcommands. To keep this guide honest, they are not documented as commands here:
- Bifurcation analysis (
bifurcation-analysis) - Implicit / DAE dynamics (
implicit-dynamics) - Weak-form derivatives (
weak-form) — available via
discovery derivative options, not a standalone command.
Run lawsynth help for the authoritative list of subcommands your build ships.