Skip to main content

blue_lang_runtime/
lib.rs

1//! The blue runtime — **one** definition of what a blue program runs against.
2//!
3//! Before this crate existed, every consumer hand-rolled its own
4//! `Interpreter::new()` + `install_primitives(...)` pair — three of them, in
5//! three crates — and they had silently drifted: none of them loaded the Lisp
6//! stdlib. So `6 % 3` lowered correctly to `(mod 6 3)`, `mod` was genuinely
7//! defined, and the program still died with `unbound symbol: mod`, because
8//! the definition lived in a stdlib nobody loaded.
9//!
10//! That is the duplication tax in its usual shape: the bug is not in any one
11//! copy, it is in there *being* copies. One function now owns the answer.
12//!
13//! ## Layers
14//!
15//! A blue interpreter is built in two layers, and both are required:
16//!
17//! 1. **Rust primitives** — arithmetic, comparison, list ops, I/O.
18//! 2. **The full tatara stdlib** — primitives, higher-order functions
19//!    (`map`/`filter`/`fold`), maps, channels, fibers, type-check, and
20//!    everything tatara defines in tatara-lisp itself
21//!    (`mod`, `rem`, `first`, `inc`, `even?`, the actor and transducer
22//!    helpers, …). Loading it is not optional garnish: blue's own operator
23//!    lowering depends on it.
24//! 3. **blue's own core** ([`stdlib`]) — strings and number conversion, which
25//!    tatara-lisp does not have in any form. A Ruby-surface language without
26//!    `length` or `upcase` is not usable, and the semantics are Ruby's
27//!    (characters, not bytes), which is why they are blue's and not the
28//!    substrate's.
29//! 4. **JSON** ([`json`]) and **cryptography** ([`crypto`]) — pure
30//!    computation, installed unconditionally, so the wasm consumer keeps them.
31//! 5. **The host layer** (`sys`, behind the `sys` feature) — every name in it
32//!    is a host import.
33//! 6. **Processes** ([`attach_node`]) — `spawn`, `send`, `receive` and the
34//!    rest of `blue_lang_proc::node`'s words, bound to a node of the
35//!    interpreter's own, so the program is that node's root process.
36
37pub mod alloc;
38pub mod collection;
39pub mod crypto;
40pub mod dispatch;
41pub mod doc_sources;
42pub mod docs;
43pub mod domain;
44#[cfg(feature = "sys")]
45pub mod entropy;
46pub mod erase;
47pub mod errors;
48pub mod host_surface;
49pub mod hosted;
50pub mod inputs;
51pub mod json;
52pub mod literal;
53pub mod messages;
54#[cfg(feature = "sys")]
55pub mod net;
56pub mod nix;
57pub mod observe;
58pub mod paths;
59pub mod pipeline;
60pub mod project_rules;
61#[cfg(feature = "sys")]
62pub mod provide;
63pub mod session;
64pub mod spread;
65pub mod stdlib;
66#[cfg(feature = "sys")]
67pub mod sys;
68pub mod timings;
69pub mod uses;
70pub mod xml;
71
72pub use erase::{erase_types, to_sexps};
73pub use inputs::{declarations, install_input_primitives, Declaration, InputError, Inputs};
74pub use pipeline::{
75    parse, parse_tree_with_depth, parse_with_depth, run, run_with_inputs, Run, RunError,
76};
77pub use stdlib::install_blue_stdlib;
78
79use tatara_lisp_eval::{install_full_stdlib_with, Interpreter};
80
81/// Build an interpreter with the complete blue runtime installed.
82///
83/// This is the *only* sanctioned way to obtain one. A caller that builds an
84/// `Interpreter` directly gets a partial runtime, and the failure shows up as
85/// an unbound symbol at the far end of a program.
86pub fn interpreter<H: 'static>(host: &mut H) -> Interpreter<H> {
87    let mut interp = Interpreter::new();
88    // The FULL substrate, not just `install_primitives`.
89    //
90    // blue called `install_primitives` + `install_lisp_stdlib_with` and got
91    // neither `install_hof` nor `install_map` — so `map`, `filter`, `fold` and
92    // every map literal were UNBOUND SYMBOLS. A language with no higher-order
93    // functions, in a workspace whose whole surface is Ruby's.
94    //
95    // Same shape as the stdlib gap this crate was created to fix: the substrate
96    // has layers, and naming them one at a time is how one gets missed. Call
97    // the composed installer.
98    install_full_stdlib_with(&mut interp, host);
99    // Layer 3: blue's own core — strings and number conversion, which
100    // tatara-lisp does not carry at all. See `stdlib` for why the
101    // character-counting semantics are blue's rather than the substrate's.
102    stdlib::install_blue_stdlib(&mut interp);
103    // Layer 4: blue's JSON surface — parse, stringify, read. Pure
104    // computation, no host imports, so it is installed unconditionally; the
105    // `wasm32-unknown-unknown` consumer keeps it. See `json` for why objects
106    // arrive as alists rather than Maps.
107    json::install_json_stdlib(&mut interp);
108    // Layer 4b: cryptography — BLAKE3 and Ed25519. Pure for the same reason
109    // JSON is: a deterministic function of its arguments, with the keypair's
110    // seed supplied by the caller rather than drawn from an entropy source,
111    // so it lowers to no import and the wasm consumer keeps it. See `crypto`.
112    crypto::install_crypto_stdlib(&mut interp);
113    nix::install_nix_stdlib(&mut interp);
114    xml::install_xml_stdlib(&mut interp);
115    // Layer 5: the host-side system surface — process, filesystem, env,
116    // clock, network. Feature-gated: every sys primitive is a host import, so the
117    // wasm consumer (which builds with `sys` OFF) keeps its zero-host-import
118    // surface by construction. Only the CLI turns it on.
119    #[cfg(feature = "sys")]
120    sys::install_sys_stdlib(&mut interp);
121    // Layer 6: processes. Bound here so every name oracle built from this
122    // interpreter (the check stage, the LSP, `blue reference`) knows them.
123    attach_node(&mut interp);
124    // Last, so installing the stdlib is not metered against the program.
125    apply_execution_bounds(&mut interp);
126    interp
127}
128
129/// Give `interp` a process node of its own: its program becomes the node's
130/// root, and `spawn` starts processes in this node and no other.
131///
132/// [`interpreter_hostless`] calls it on every fork, so two programs never
133/// share processes. A door that forks an interpreter per unit of work (a test
134/// per fork) calls it on each fork for the same reason.
135pub fn attach_node<H: 'static>(interp: &mut Interpreter<H>) {
136    dispatch::install(interp);
137    blue_lang_proc::install(interp, blue_lang_proc::Node::new(node_config()));
138}
139
140/// [`attach_node`] for a program that runs under `frame`: every process it
141/// starts is held to the same frame, so a host name the program may not call
142/// is refused in its children too. Without this a spawned child would run on
143/// the full runtime and a frame would be one `spawn` away from meaningless.
144pub fn attach_node_in_frame<H: 'static>(interp: &mut Interpreter<H>, frame: &blue_lang_waku::Waku) {
145    attach_node_held(interp, &Hold::from(frame.clone()));
146}
147
148/// [`attach_node_in_frame`] with the hold's providers bound in every child.
149pub fn attach_node_held<H: 'static>(interp: &mut Interpreter<H>, hold: &Hold) {
150    let mut config = node_config();
151    let refused = hold.withheld_host_names();
152    #[cfg(feature = "sys")]
153    let provisions = hold.provisions.clone();
154    config.child = std::sync::Arc::new(move || {
155        let mut child = child_interpreter();
156        refuse_host_names(&mut child, &refused, "this process's frame");
157        #[cfg(feature = "sys")]
158        provide::provide(&mut child, &provisions);
159        child
160    });
161    dispatch::install(interp);
162    blue_lang_proc::install(interp, blue_lang_proc::Node::new(config));
163}
164
165/// What a run is held to: the frame it may reach the host through, and the
166/// capabilities pure providers stand in for.
167#[derive(Clone, Debug, PartialEq, Eq)]
168pub struct Hold {
169    pub frame: blue_lang_waku::Waku,
170    pub provisions: blue_lang_waku::Provisions,
171}
172
173impl From<blue_lang_waku::Waku> for Hold {
174    fn from(frame: blue_lang_waku::Waku) -> Self {
175        Self {
176            frame,
177            provisions: blue_lang_waku::Provisions::default(),
178        }
179    }
180}
181
182/// The name a scoped provider binds the host name `word` under. No blue
183/// source can write it, so only the references [`Hold::rebind_scoped`] moves
184/// onto it reach the provider.
185#[must_use]
186pub fn provided_name(word: &str) -> String {
187    format!("%provided/{word}")
188}
189
190impl Hold {
191    /// Every host name neither the frame grants nor a whole-program provider
192    /// stands in for. A scoped provider's names stay here: code outside its
193    /// packages gets the frame's answer.
194    #[must_use]
195    pub fn withheld_host_names(&self) -> Vec<(&'static str, blue_lang_waku::Capability)> {
196        let reach = self.provisions.widen(&self.frame.reach);
197        blue_lang_waku::Capability::host_effects()
198            .into_iter()
199            .filter(|cap| !reach.grants(*cap))
200            .flat_map(|cap| cap.names().into_iter().map(move |n| (n, cap)))
201            .collect()
202    }
203
204    /// Bind the hold into `interp`: a breach for every withheld host name,
205    /// then each whole-program provider over its capability's names and each
206    /// scoped one under [`provided_name`].
207    pub fn bind<H: 'static>(&self, interp: &mut Interpreter<H>, who: &'static str) {
208        refuse_host_names(interp, &self.withheld_host_names(), who);
209        #[cfg(feature = "sys")]
210        provide::provide(interp, &self.provisions);
211    }
212
213    /// Move each scoped provider's host names, where a file of a package it
214    /// serves writes them in `forms`, onto [`provided_name`]. A provider
215    /// serves the packages its `for:` names and every package they `use`, by
216    /// the imports `program` resolved; `forms` is `program`'s evaluated tree,
217    /// top-level form for top-level form. Returns the host names the entry
218    /// file may not write: [`Hold::withheld_host_names`] less those a scoped
219    /// provider serves the entry's own package.
220    pub fn rebind_scoped(
221        &self,
222        program: &uses::ResolvedProgram,
223        forms: &mut [tatara_lisp::Spanned],
224    ) -> Vec<(&'static str, blue_lang_waku::Capability)> {
225        let served: Vec<(
226            blue_lang_waku::Capability,
227            std::collections::BTreeSet<String>,
228        )> = self
229            .provisions
230            .scoped()
231            .map(|(cap, named)| (cap, program.reached_from(named)))
232            .collect();
233        let serves = |package: Option<&str>, cap: blue_lang_waku::Capability| {
234            package.is_some_and(|p| served.iter().any(|(c, s)| *c == cap && s.contains(p)))
235        };
236        if !served.is_empty() {
237            uses::rename_written(program, forms, &|package, name| {
238                let cap = blue_lang_waku::Provision::capability_of(name)?;
239                serves(package, cap).then(|| provided_name(name))
240            });
241        }
242        let entry = program
243            .file(uses::ResolvedProgram::ENTRY)
244            .and_then(|f| f.package.as_deref());
245        self.withheld_host_names()
246            .into_iter()
247            .filter(|(_, cap)| !serves(entry, *cap))
248            .collect()
249    }
250}
251
252/// Every host name `frame` does not grant, with its capability.
253#[must_use]
254pub fn ungranted_host_names(
255    frame: &blue_lang_waku::Waku,
256) -> Vec<(&'static str, blue_lang_waku::Capability)> {
257    Hold::from(frame.clone()).withheld_host_names()
258}
259
260/// The kind of the fatal error a capability breach raises.
261pub const BREACH: &str = "capability-breach";
262
263/// The fatal error a call to `name` raises when `who` does not grant `cap`.
264/// No `rescue` catches it: the run ends.
265#[must_use]
266pub fn breach(
267    name: &str,
268    cap: blue_lang_waku::Capability,
269    who: &str,
270    at: tatara_lisp::Span,
271) -> tatara_lisp_eval::EvalError {
272    tatara_lisp_eval::EvalError::fatal(
273        BREACH,
274        format!(
275            "`{name}` reaches the host ({}), which {who} does not grant",
276            cap.label()
277        ),
278        at,
279    )
280}
281
282/// Is `e` a capability breach?
283#[must_use]
284pub fn is_breach(e: &tatara_lisp_eval::EvalError) -> bool {
285    matches!(e, tatara_lisp_eval::EvalError::Fatal { kind, .. } if &**kind == BREACH)
286}
287
288/// Bind each of `refused` that `interp` defines to a breach naming `who`.
289pub fn refuse_host_names<H: 'static>(
290    interp: &mut Interpreter<H>,
291    refused: &[(&'static str, blue_lang_waku::Capability)],
292    who: &'static str,
293) {
294    refuse_host_names_noting(interp, refused, who, |_, _, _| {});
295}
296
297/// [`refuse_host_names`], calling `note` with the host, the capability and
298/// the name before each breach is raised.
299pub fn refuse_host_names_noting<H: 'static>(
300    interp: &mut Interpreter<H>,
301    refused: &[(&'static str, blue_lang_waku::Capability)],
302    who: &'static str,
303    note: fn(&mut H, blue_lang_waku::Capability, &'static str),
304) {
305    for &(name, cap) in refused {
306        if interp.lookup_global(name).is_none() {
307            continue;
308        }
309        interp.register_fn(
310            name,
311            tatara_lisp_eval::ffi::Arity::Any,
312            move |_: &[tatara_lisp_eval::Value], host: &mut H, span| {
313                note(host, cap, name);
314                Err(breach(name, cap, who, span))
315            },
316        );
317    }
318}
319
320/// The node every interpreter's processes run under: the execution bounds
321/// per incarnation, a scheduling quantum, and the host's clock when there is
322/// a host (a node with no host has a virtual clock: time passes only when
323/// every process waits).
324fn node_config() -> blue_lang_proc::NodeConfig {
325    let b = execution_bounds();
326    let mut config = blue_lang_proc::NodeConfig::new(std::sync::Arc::new(child_interpreter));
327    config.budget = tatara_lisp_eval::vm::Budget {
328        fuel: b.max_steps,
329        max_depth: Some(b.max_call_depth),
330        quantum: Some(blue_lang_proc::node::DEFAULT_QUANTUM),
331    };
332    config.clock = if cfg!(feature = "sys") {
333        blue_lang_proc::ClockKind::Real
334    } else {
335        blue_lang_proc::ClockKind::Virtual
336    };
337    config
338}
339
340/// A fresh interpreter for a child process: the hostless base, forked. The
341/// node adds its child words to it.
342fn child_interpreter() -> Interpreter<()> {
343    let mut interp = match HOSTLESS_BASE.lock() {
344        Ok(base) => base.fork(),
345        Err(_) => interpreter(&mut ()),
346    };
347    apply_execution_bounds(&mut interp);
348    interp
349}
350
351/// The execution bounds every interpreter this crate builds runs under.
352static EXECUTION_BOUNDS: std::sync::OnceLock<ExecutionBounds> = std::sync::OnceLock::new();
353
354/// How much a blue program may do before it is refused: nested calls and
355/// evaluation steps.
356///
357/// Both are BOUNDS in the sense `blue-lang-cli`'s `config` module admits: a
358/// terminating program under bounds above its cost returns the same value, and
359/// one that exceeds either is refused with a catchable `depth-exceeded` or
360/// `fuel-exhausted` error naming the limit and the function — never a process
361/// abort, which is what exceeding the host stack was before tatara-lisp-eval
362/// 0.3.63. The walker and the VM run under the same bounds and refuse with the
363/// same message.
364#[derive(Debug, Clone, Copy, PartialEq, Eq)]
365pub struct ExecutionBounds {
366    /// Nested (non-tail) calls alive at once. Tail calls do not count.
367    pub max_call_depth: usize,
368    /// Evaluation steps per run; `None` is unbounded.
369    pub max_steps: Option<usize>,
370}
371
372impl ExecutionBounds {
373    /// Depth at tatara-lisp-eval's `DEFAULT_MAX_DEPTH`, steps unbounded.
374    ///
375    /// The two defaults differ for one reason: whether a program that works
376    /// today keeps working. A recursion past 100k frames never worked — it
377    /// aborted the process — so bounding depth removes no behaviour. A run
378    /// longer than any finite step count did work (simulations, tools on
379    /// nodes), so a default step bound would remove it; for a few hours of
380    /// 2026-09-29 blue shipped 50M (~3 s) and did exactly that. A host that
381    /// runs code it did not write, or a test that must see a runaway end,
382    /// sets `max_steps` itself.
383    pub const DEFAULT: Self = Self {
384        max_call_depth: tatara_lisp_eval::vm::DEFAULT_MAX_DEPTH,
385        max_steps: None,
386    };
387}
388
389/// Name the bounds for this process. The CLI calls this once with the resolved
390/// `BlueConfig`, before any interpreter is built; an embedder that never calls
391/// it runs under [`ExecutionBounds::DEFAULT`]. Only the first call counts,
392/// like `sys::set_program_args`.
393pub fn set_execution_bounds(bounds: ExecutionBounds) {
394    let _ = EXECUTION_BOUNDS.set(bounds);
395}
396
397/// The bounds interpreters are built with.
398#[must_use]
399pub fn execution_bounds() -> ExecutionBounds {
400    EXECUTION_BOUNDS
401        .get()
402        .copied()
403        .unwrap_or(ExecutionBounds::DEFAULT)
404}
405
406fn apply_execution_bounds<H: 'static>(interp: &mut Interpreter<H>) {
407    let b = execution_bounds();
408    // No quantum, so the tree-walker refuses nothing of this budget.
409    let _ = interp.set_budget(tatara_lisp_eval::vm::Budget {
410        fuel: b.max_steps,
411        max_depth: Some(b.max_call_depth),
412        quantum: None,
413    });
414}
415
416/// The prebuilt hostless substrate, built once per process.
417///
418/// **Measured 2026-08-01, and this is the entire reason the fork exists.**
419/// Profiling a trivial blue program found the run dominated by ONE thing:
420///
421/// ```text
422/// parse                     9.6 µs
423/// check                     1.6 µs
424/// interpreter_hostless()  5 820 µs   <- 98.4% of the run
425/// ```
426///
427/// Splitting that further: `Interpreter::new()` is 24 µs and
428/// `install_full_stdlib_with` is the rest — the stdlib was being rebuilt from
429/// scratch on every single `run()`, and blue's whole surface (`map`, `filter`,
430/// string ops) lives in it, so no program could avoid the cost.
431///
432/// `fork` already existed upstream for exactly this, and blue simply was not
433/// using it. tatara-lisp-eval's own `fork_cost.rs` opens with *"cheapness is
434/// the entire reason it exists"* — the capability was built, documented and
435/// guarded, and the consumer kept paying full price beside it.
436///
437/// | | per call |
438/// |---|---|
439/// | rebuild (`Interpreter::new` + `install_full_stdlib_with`) | **9.03 ms** |
440/// | `fork()` of a prebuilt base | **0.102 ms** |
441///
442/// **88× on the dominant cost.** This is purgatory's computation-layer clause
443/// made real: the stdlib is the most-reused computation in the language, and
444/// it was being discarded and recomputed rather than resurrected.
445///
446/// Isolation is `fork`'s contract, not an assumption made here — its
447/// correctness gates live upstream in `fork.rs`, and `fork_cost.rs` guards
448/// against a future change that deep-copies (which would keep every
449/// correctness test green while silently restoring the cost this removes).
450static HOSTLESS_BASE: std::sync::LazyLock<std::sync::Mutex<Interpreter<()>>> =
451    std::sync::LazyLock::new(|| {
452        std::sync::Mutex::new(timings::stage(timings::Stage::Runtime, || {
453            interpreter(&mut ())
454        }))
455    });
456
457/// The common host-free case.
458///
459/// Forks the process-wide base rather than rebuilding the stdlib. Falls back to
460/// a full build if the lock is poisoned: a poisoned lock means another thread
461/// panicked mid-fork, and answering a correct-but-slow interpreter beats
462/// propagating someone else's panic into an unrelated caller.
463pub fn interpreter_hostless() -> Interpreter<()> {
464    let mut interp = match HOSTLESS_BASE.lock() {
465        Ok(base) => base.fork(),
466        Err(_) => interpreter(&mut ()),
467    };
468    // Again after the fork: the base may have been built before the bounds
469    // were named, and a fork inherits whatever its base had.
470    apply_execution_bounds(&mut interp);
471    // A node per fork: the base's own node would be shared by every program.
472    attach_node(&mut interp);
473    interp
474}
475
476/// Lift spanless forms into what the evaluator eats.
477///
478/// **No longer on the `blue run` path, and that is the point.**
479/// [`erase::erase_types`] now takes and returns `Spanned`, so
480/// `pipeline::run_in_surface` hands the evaluator the *parser's own* tree with
481/// the author's positions on it — there is nothing left to lift. This
482/// function survives for a caller that genuinely holds spanless `Sexp` and
483/// has no position to preserve, and as the subject of the two tests below
484/// that pin why the hop it replaced was unsafe.
485///
486/// `Interpreter::eval_program` takes `&[Spanned]`; blue's stages produced
487/// `Sexp`. Two callers bridged that gap by *printing the tree and reading it
488/// back* — `forms.map(ToString::to_string).join("\n")` into
489/// `tatara_lisp::read_spanned`. Both stopped, and the round trip is gone from
490/// the pipeline.
491///
492/// **Why it had to go.** Printing a tree we already hold and re-parsing it puts
493/// the reader's lexer between blue and its own output, for nothing — and the
494/// printer and the reader are **not inverses**. `Atom::Str`'s `Display` escapes
495/// its payload and its own docs explain at length why; the `Atom::Symbol` arm
496/// is a bare `write_str`. So a symbol whose text carries a separator prints as
497/// several tokens and reads back as several symbols: a well-formed tree with a
498/// different meaning, no error raised. Measured 2026-08-02 —
499/// `pipeline::tests::the_round_trip_is_not_the_identity_in_general` pins which
500/// separators are silent and which are loud. The only thing that kept this from
501/// biting was blue happening not to emit those bytes. A stage that never
502/// serialises cannot be mis-read.
503///
504/// **What is given up: spans.** The old path's spans pointed into the
505/// re-printed lisp text, a buffer no human ever wrote and no diagnostic could
506/// usefully cite — they were positions in blue's own output, not in the
507/// author's source. `Span::synthetic` says the same thing honestly.
508///
509/// **Carrying real blue-source spans through erasure was the separate piece of
510/// work, and it is done — but NOT the way this doc predicted.** It said the
511/// work "would start from `Spanned::from_sexp_at`". It did not, and could not:
512/// `from_sexp_at` is a *lift*, stamping ONE span across a whole subtree, so
513/// starting there would have replaced a tree of synthetic spans with a tree of
514/// identical wrong ones. The actual shape was to stop projecting in the first
515/// place — erasure walks `Spanned` and keeps each node's own span, because it
516/// only ever deletes (see [`erase`]). **A pointer at the nearest-looking API is
517/// how a reader is sent to the wrong starting point**; the note is corrected
518/// here rather than deleted, because the wrong lead was load-bearing enough to
519/// be worth naming.
520#[must_use]
521pub fn lower_to_spanned(forms: &[tatara_lisp::Sexp]) -> Vec<tatara_lisp::Spanned> {
522    forms
523        .iter()
524        .map(tatara_lisp::Spanned::from_sexp_synthetic)
525        .collect()
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531    use tatara_lisp_eval::Value;
532
533    fn eval(src: &str) -> Value {
534        let forms = tatara_lisp::read_spanned(src).expect("read");
535        let mut interp = interpreter_hostless();
536        interp.eval_program(&forms, &mut ()).expect("eval")
537    }
538
539    /// Every layer is present. A test that only checked layer 1 is exactly
540    /// what let the stdlib gap survive.
541    #[test]
542    fn both_layers_are_installed() {
543        // Layer 1: a Rust primitive.
544        assert!(matches!(eval("(+ 1 2)"), Value::Int(3)));
545        // Layer 2: a stdlib definition, which is the layer that was missing.
546        assert!(matches!(eval("(mod 7 3)"), Value::Int(1)));
547        assert!(matches!(eval("(inc 41)"), Value::Int(42)));
548        assert!(matches!(eval("(first (list 9 8))"), Value::Int(9)));
549        // Layer 3: blue's own core — string and number conversion.
550        assert!(matches!(eval(r#"(length "héllo")"#), Value::Int(5)));
551        // Layer 4: the JSON surface.
552        assert!(matches!(
553            eval(r#"(json_parse "{\"outcome\":\"ok\"}")"#),
554            Value::List(_)
555        ));
556        // Layer 4b: cryptography.
557        assert!(matches!(eval(r#"(blake3_hex "")"#), Value::Str(_)));
558    }
559
560    /// Anti-vacuity: a bare interpreter really does LACK layer 2, so the test
561    /// above is measuring the runtime's contribution and not a property the
562    /// interpreter has for free.
563    #[test]
564    fn a_bare_interpreter_lacks_the_stdlib() {
565        let forms = tatara_lisp::read_spanned("(mod 7 3)").expect("read");
566        let mut bare = Interpreter::new();
567        tatara_lisp_eval::install_primitives(&mut bare);
568        assert!(
569            bare.eval_program(&forms, &mut ()).is_err(),
570            "if a bare interpreter already resolved `mod`, this crate would be measuring nothing"
571        );
572    }
573}