laplace is a source-to-source preprocessor for Stan: it
compiles .laplace files down to plain .stan files, adding a package manager
and namespaced imports (pkg::func()) on top of a language that has neither
natively.
laplace never hides what actually gets sent to Stan. laplace build always
produces a real, readable, committable .stan file, and laplace itself is
never a runtime dependency of your model — once build/model.stan exists, you
can hand it to stanc/cmdstan without laplace installed at all.
Libraries can depend on other libraries: a package declares its own
[dependencies], laplace resolves the whole graph (unifying diamonds,
rejecting cycles), and pins it in laplace.lock. See Libraries that depend on
libraries.
laplace is built from source with Cargo (edition 2021 — any reasonably recent stable Rust toolchain works):
cargo install --path . --root ~/.localAdd the install directory's bin/ to your PATH if it isn't already there
(e.g. export PATH="$HOME/.local/bin:$PATH"), then verify:
laplace --helplaplace does not require Stan or stanc to be installed for anything except
the optional --validate flag on laplace build (see Building),
which shells out to stanc on PATH or wherever LAPLACE_STANC points.
This walks through the smallest possible example: one package, one function, one model that calls it.
First, a package. A package is just a directory with a .stan file and a
laplace.toml. Here's a package called mathutils with a single exported
function:
// mathutils.stan
// @laplace
// @brief Doubles a real value.
// @param x The value to double.
// @return 2 * x.
// @example mathutils::double(3.0)
real double(real x) {
return 2 * x;
}# laplace.toml
name = "mathutils"
version = "1.0.0"
exports = ["double"](How to make a package like this installable is covered in full in Creating
a library — for this walkthrough, assume mathutils is
already sitting in your local registry at ~/.laplace/registry/mathutils/1.0.0/.)
Now, in a project directory, write a model that imports it:
// model.laplace
library {
import mathutils
}
data {
real x;
}
transformed data {
real y = mathutils::double(x);
}Resolve and install the dependency, then build:
laplace add mathutils
laplace install
laplace build model.laplace$ laplace add mathutils
added mathutils@1.0.0
$ laplace install
installed 1 package
$ laplace build model.laplace
wrote build/model.stan (21 lines, 1 dependency)
And build/model.stan:
functions {
// @laplace
// @brief Doubles a real value.
// @param x The value to double.
// @return 2 * x.
// @example mathutils::double(3.0)
real mathutils__double(real x) { // <- pkg::func mangled to pkg__func
return 2 * x;
}
}
data { // <- data/parameters/model blocks:
real x; // byte-identical to the source
}
transformed data {
real y = mathutils__double(x); // <- call site rewritten to match
}What changed, and what didn't:
- The
library { ... }block is gone entirely — it's a laplace-only construct and has no meaning tostanc. mathutils'sdoublefunction was renamedmathutils__double(namespacing is name-mangling, not a real Stan feature) and spliced into afunctions {}block, doc comment and all —// @laplacecomments are plain Stan comments, so they survive into the compiled output.- The call site
mathutils::double(x)becamemathutils__double(x). - The
data { }andtransformed data { }blocks are otherwise byte-for-byte what you wrote — laplace never parses or touches Stan statements outside ofpkg::func(rewriting.
This section is self-contained — you can follow it without having read anything else in this README.
No files need to move or be duplicated from an existing Stan-functions repo.
Take a directory of .stan files that already work as ordinary Stan, and add
one file: laplace.toml. That's it — it's now a laplace package.
Functions you want documented (and, as covered below, exportable) get a
comment block starting with // @laplace directly above them:
// @laplace
// @brief Squared exponential (RBF) covariance matrix.
// @param x Vector of input locations.
// @param alpha Marginal standard deviation of the GP.
// @param rho Length-scale of the GP.
// @return An N x N positive semi-definite covariance matrix.
// @example gps::rbf_cov(x, 1.0, 0.5)
matrix rbf_cov(vector x, real alpha, real rho) {
return gp_exp_quad_cov(x, alpha, rho);
}@brief, @param (repeatable, one per parameter), @return, and @example
are all recognized. These are plain // Stan comments — stanc ignores them
completely, so the file stays valid, usable Stan on its own even if you never
run it through laplace.
A function does not need an // @laplace comment to exist in the file.
Undocumented functions are still scanned — so they get renamed correctly if
an exported function calls them internally — they just won't show up in
laplace doc output, and laplace init (below) won't guess them as exports.
name = "gps"
version = "1.0.0"
exports = ["rbf_cov"]exports is the actual privacy boundary, not the doc comment. A function
that's present in the .stan file but absent from exports cannot be called
as pkg::func from outside the package — regardless of whether it has an
@laplace comment. Documentation and visibility are two separate concerns.
One caveat about non-exported functions: laplace leaves their names alone
(only exports get the pkg__ prefix), and Stan has a single flat function
namespace. If two packages in the same build both define a private helper
called softplus, the build fails with an error naming both packages rather
than silently emitting two definitions. Give private helpers distinctive
names, or export them.
Rather than hand-writing laplace.toml, run laplace init inside a
directory of .stan files to generate a starter manifest. It guesses name
from the directory name, sets version = "0.1.0", and pre-fills exports
with every @laplace-documented function — printing which functions were
included and which were left out so you can adjust either list by hand:
$ laplace init
wrote laplace.toml for `mypkg`
included 1 exported function: add_one
note: 1 undocumented function left out of exports (add manually if this guess is wrong): helper
It refuses to run (rather than overwrite) if laplace.toml already exists in
that directory.
laplace resolves dependencies from two kinds of source, and you can mix both in the same project:
Local filesystem registry — the default. Place the package directory
under the registry root, named <registry>/<pkg-name>/<version>/:
~/.laplace/registry/gps/1.0.0/
laplace.toml
gps.stan
The registry root defaults to ~/.laplace/registry, overridable with the
LAPLACE_REGISTRY environment variable. Once it's there, laplace add gps
resolves against it directly.
Git repositories — pin a dependency straight to a git repo instead of a
registry entry, either by hand-editing laplace.toml:
[dependencies]
gps = { git = "https://github.com/user/gps-stan", tag = "0.1.0" }
# or, pinned to a commit instead of a tag:
gps2 = { git = "https://github.com/user/gps2-stan", rev = "abc123" }or via the CLI:
laplace add gps --git https://github.com/user/gps-stan --tag 0.1.0Exactly one of tag/rev must be set per git dependency. A git-sourced
package is otherwise treated identically to a registry one from that point
on — same checksum in the lock, same install cache layout.
By default the package is expected at the root of the repository: its
laplace.toml sits next to the repo's .git. If the repo keeps the package
below the top level — beside a README, or one of several packages in a
monorepo — point subdir at the directory holding its laplace.toml:
[dependencies]
gps = { git = "https://github.com/user/gps-stan", tag = "0.1.0", subdir = "laplace" }laplace add gps --git https://github.com/user/gps-stan --tag 0.1.0 --subdir laplaceThe subdirectory is part of the source's identity: it is recorded in the lock
as git+<url>@<ref>#<subdir>, only that directory is checksummed and
installed, and two dependencies naming the same repo and ref but different
subdirectories are different packages (and conflict, like any other two git
sources for one package name). It must be a plain relative path inside the
repository — an absolute path or one containing .. is rejected, in
laplace.toml and in laplace.lock alike.
A library can build on another library. Say regression wants to use stats's
mean_. Two things change relative to a leaf package.
1. The manifest gets a [dependencies] table, with exactly the same syntax
as a project's laplace.toml:
# regression/laplace.toml
name = "regression"
version = "1.0.0"
exports = ["centre"]
[dependencies]
stats = "^1.0"2. The source file becomes .laplacelib instead of .stan, so it can carry
a library { } block and pkg::func() calls:
// regression/regression.laplacelib
library {
import stats
}
// @laplace
// @brief Centre a vector on its mean.
// @param x The vector to centre.
// @return `x` minus its mean.
vector centre(vector x) {
return x - stats::mean_(x);
}.laplacelib is a relaxed dialect: bare function definitions, an optional
functions { } wrapper around them, and an optional library { } block. The
model-shaped blocks (data, transformed data, parameters, transformed parameters, model, generated quantities) are rejected with a clear error —
a library provides functions to a model, it isn't a model. A package can mix
.laplacelib and plain .stan files freely; the .stan ones are ordinary
Stan, passed through verbatim, and can't import anything.
Nothing new. A project that wants regression just adds it, and stats comes
along automatically:
$ laplace add regression
added regression@1.0.0
$ laplace install
installed 2 packages
$ laplace build model.laplace
wrote build/model.stan (33 lines, 1 dependency + 1 transitive)
laplace.toml still records only what you asked for; laplace.lock records the
whole graph, including the edges between packages:
root = ["regression"]
[[package]]
name = "regression"
version = "1.0.0"
checksum = "sha256:..."
source = "registry"
dependencies = ["stats"]
[[package]]
name = "stats"
version = "1.0.0"
checksum = "sha256:..."
source = "registry"
dependencies = []That's what lets laplace install restore the full graph on a new machine
without re-resolving a single version range.
regression importing stats does not give your model access to
stats. If you want to call stats::mean_ yourself, laplace add stats and
import it in your own library { } block — at which point laplace makes sure
both of you agree on one version of it. Libraries can't borrow each other's
dependencies either: a package may only call packages listed in its own
[dependencies].
There is no re-export mechanism yet.
A build contains exactly one version of any package. When several dependents want the same package, laplace picks one version satisfying all of their ranges (the newest that qualifies). If no version qualifies, the build stops and names everyone involved:
error: cannot pick one version of `stats`:
this project requires `^1`
`regression@1.0.0` requires `^2`
available versions: 1.0.0, 2.0.0
The fix is to widen one of the ranges, not to run two copies — two copies would
mangle to the same stats__mean_ name and clobber each other.
Circular dependencies are rejected outright, with the cycle printed:
error: dependency cycle: alpha -> beta -> alpha
laplace add and laplace update only move the package you name. Everything
else stays at its locked version unless a constraint forces it to move, so
adding one dependency never silently bumps the rest of your graph.
Import a package in the library { } block, bare (latest resolved version)
or pinned:
library {
import gps
import stats@1.2.0
}...then call its exported functions as pkg::func(...) anywhere in the rest
of the file.
Dependencies are tracked across two files:
laplace.toml— hand-edited, loose version ranges (gps = "^1.0") or git sources. This is where you state what you're willing to accept.laplace.lock— machine-written, exact resolved versions and checksums for the whole graph, transitive dependencies included, plus the edges between them. This is what actually gets installed. Commit it to git.
laplace install reads only the lock, never laplace.toml's ranges —
that's what makes a fresh clone reproducible: two machines with the same
laplace.lock always install identical package versions.
The three commands, and when to reach for each:
laplace add <pkg>[@version]— introducing a new dependency. Resolves a version matching the range (or pins a new one), pulls in whatever that package itself depends on, fetches it all, and updates bothlaplace.tomlandlaplace.lock.laplace update <pkg>— bumping an existing dependency to the latest version matching its current range inlaplace.toml. Only<pkg>moves; the rest of the graph keeps its pins unless a constraint forces otherwise.laplace install— restoring an already-locked project on a new machine (e.g. right aftergit clone). Readslaplace.lockonly.
laplace build <file.laplace> [-o build/model.stan]By default, output goes to build/<stem>.stan — e.g. model.laplace builds
to build/model.stan. Override with -o/--output.
--split-functions changes the output shape. By default every imported
function is inlined into the compiled .stan file, so you ship one
self-contained file. With --split-functions, each directly imported package
instead gets its own <pkg>.stanfunctions file next to the output, and the
functions { } block gets an #include line per package:
$ laplace build model.laplace --split-functions
wrote build/model.stan (15 lines, 1 dependency + 1 transitive)
wrote build/regression.stanfunctions (stats, regression)
note: keep the .stanfunctions files next to build/model.stan and compile with the include path
set to that directory -- `stanc --include-paths=build`, or `cmdstan_model(..., include_paths = "build")` in cmdstanr
// build/model.stan
functions {
#include "regression.stanfunctions"
}
data {
int<lower=1> N;
vector[N] y;
}
model {
vector[N] c = regression__centre(y);
c ~ std_normal();
}// build/regression.stanfunctions
// Generated by laplace from package `regression` 1.0.0. Do not edit.
// Bundled dependencies of `regression`: stats 1.0.0.
// @laplace
// @brief Arithmetic mean of a vector.
real stats__mean_(vector x) {
return sum(x) / num_elements(x);
}
// @laplace
// @brief Centre a vector on its mean.
vector regression__centre(vector x) {
return x - stats__mean_(x);
}Notes on split mode:
- Functions you wrote yourself in the
.laplacefile'sfunctions { }block stay inline. Only imported package functions move out. - A transitive dependency has no file of its own — it's flattened into the file
of the package that pulled it in (
statsabove). laplace never generates an#includeof one.stanfunctionsfile from another, so "what has to ship next to my.stanfile" is answerable from yourlibrary { }block. If two of your direct imports share a dependency, it's still emitted exactly once, and the#includelines are ordered so every function is defined before it's used. - The
.stanfunctionsfiles must travel with the.stanfile, andstancmust be told where they are: it does not resolve#includerelative to the including file, so pass--include-paths=<dir>(orinclude_paths =in cmdstanr).laplace build --validatedoes this for you. - A project with no imports is unaffected —
--split-functionsis a no-op. - It's off by default because one self-contained
.stanfile is the more portable artifact. Turn it on when the inlined output has grown too big to read comfortably.
--check is for CI: it runs codegen and diffs the result against the
existing output file instead of writing, exiting non-zero if they differ.
Use it to catch a committed .stan file that's gone stale relative to its
.laplace source. With --split-functions it checks the .stanfunctions
files too.
--validate shells out to stanc after writing, to type-check the
generated file. It passes the output directory as an include path and sends
stanc's generated C++ to a scratch directory, so nothing but the .stan
(and any .stanfunctions) lands in build/. It's off by default, so a normal laplace build never
requires Stan to be installed. Point it at a specific binary with the
LAPLACE_STANC environment variable if stanc isn't on PATH.
Build output is deterministic: the same .laplace source plus the same
laplace.lock always produces byte-identical .stan output. It's meant to
be committed to git — a human should be able to open build/model.stan and
fully understand it without laplace installed at all.
laplace doc <pkg>::<func>$ laplace doc mathutils::double
mathutils::double(x: real) -> real
Doubles a real value.
Parameters:
x The value to double.
Returns:
2 * x.
Example:
mathutils::double(3.0)
This reads a docs.json sidecar that's generated once, at install time —
there's no network access or Stan re-parsing at lookup time. A function with
no @laplace comment still prints its signature, with a note that no
documentation is available for it.
A few choices are worth stating outright, because they're baked into published packages' symbol names and into the lockfile format, and are expensive to change later.
laplace is not a Stan compiler. It never parses Stan's grammar or
semantics. Only the library { } block is parsed in depth, plus a shallow
scan for top-level block keywords and function signatures. Everything else —
every statement in every block — is opaque text, passed through byte for byte
except for pkg::func( call-site rewriting. That's the reason the compiled
output is trustworthy: laplace can't quietly change the meaning of code it
never understood.
Build output is deterministic. The same source plus the same
laplace.lock always produces byte-identical .stan output. No timestamps,
no hash-map iteration order leaking into generated code. It's meant to be
committed and diffed.
The lockfile is a graph, not a list. laplace.lock records root (your
direct dependencies) and a dependencies list on every package, so
laplace install reconstructs the entire transitive graph without re-resolving
a single version range. Locks written before this format still read correctly:
a missing dependencies means "leaf", and a missing root means "every
locked package is direct".
One version of any package per build. Every requirement on a package name
— from your laplace.toml and from every package's own [dependencies] — is
collected, and one version satisfying all of them is chosen. If none exists,
the build stops and names every requirer. Two copies of a package would mangle
to the same pkg__func names and silently clobber each other, so this isn't
negotiable. Cycles are rejected with the path printed.
Mangling is a plain pkg__func prefix, for transitive dependencies too —
no content hash, no version fragment. That's safe because of the
one-version-per-build rule, which makes a package name a unique prefix, and it
keeps --split-functions output readable. If single-version unification is
ever relaxed, the mangling scheme has to be revisited in the same change.
Non-exported functions keep their names. Only exports get the pkg__
prefix. Since Stan has one flat function namespace, two packages defining the
same private helper is a hard error naming both, rather than a silent double
definition. Auto-mangling private names would be tidier but would change the
compiled output of every existing project, and byte-identical output is worth
more.
Imports are private, and there are no re-exports. A package may call only
the packages in its own [dependencies]; your model may call only the
packages in its own library { } block. Depending on something transitively
doesn't put it in scope.
Transitive dependencies are flattened, never chained. In
--split-functions mode, laplace never emits an #include of one
.stanfunctions file from another, so "what has to ship next to my .stan
file" is answerable from the library { } block alone. Each package is still
emitted exactly once across the build, ordered so every function is defined
before it's used.