Skip to main content

sim_lib_cookbook/
run.rs

1//! The `cookbook:run` algorithm: decode a recipe's setup through its declared
2//! codec, evaluate it, encode the result, and check declared expectations.
3//!
4//! Per the design (Section 7) this is capability-gated: running a recipe is
5//! read-eval, so the runtime must hold the read-eval capability. Listing and
6//! showing recipes is not gated.
7//!
8//! First cut: the setup decodes to a single top-level form (form 0). The data
9//! model already carries a `Vec` of results and expectations, so multi-form
10//! setups are a forward-compatible extension.
11
12use sim_codec::{Input, decode_eval_expr_with_codec, decode_with_codec, encode_value_with_codec};
13use sim_cookbook::{CheckResult, RecipeCard, RecipeRun};
14use sim_kernel::{
15    CapabilitySet, Cx, EncodeOptions, Error, Expr, ReadPolicy, Result, Symbol, TrustLevel,
16    read_construct_capability, read_eval_capability,
17};
18
19use crate::catalog::{EmptyCatalog, LibCatalog, load_requires};
20
21/// Error unless the runtime holds the read-eval capability.
22pub fn require_eval_capability(cx: &Cx) -> Result<()> {
23    if cx.capabilities().contains(&read_eval_capability()) {
24        Ok(())
25    } else {
26        Err(Error::CapabilityDenied {
27            capability: read_eval_capability(),
28        })
29    }
30}
31
32/// Lib ids in `card.requires` that are not currently loaded.
33///
34/// A requirement matches a loaded lib by its fully qualified id
35/// (`namespace/name`) or by its unqualified `name` alone, so `numbers-f64`
36/// matches a lib whose id is `lisp/numbers-f64`.
37pub fn missing_requires(cx: &Cx, card: &RecipeCard) -> Vec<String> {
38    let loaded: Vec<(String, String)> = cx
39        .registry()
40        .libs()
41        .iter()
42        .map(|lib| {
43            (
44                lib.manifest.id.as_qualified_str(),
45                lib.manifest.id.name.to_string(),
46            )
47        })
48        .collect();
49    card.requires
50        .iter()
51        .filter(|req| {
52            !loaded
53                .iter()
54                .any(|(qualified, name)| qualified == *req || name == *req)
55        })
56        .cloned()
57        .collect()
58}
59
60/// Lower the pratt operator nodes (`Infix`/`Prefix`/`Postfix`) a language codec
61/// (algol, ...) produces into the `Call`s the evaluator applies, recursing
62/// through call args and structural containers but PRESERVING list/vector/map
63/// structure -- unlike the `Term` round-trip, which flattens every list to a
64/// `Datum` and so cannot carry a special form's structural args (`let` bindings,
65/// `match` clauses). This is what lets an operator surface (`1 + 2 * 3`) compute
66/// (COOK8.05) while the special-form organs still run (COOK8.03). For a codec
67/// whose surface has no operator nodes (lisp, json) it is the identity.
68fn resolve_operators(expr: Expr) -> Expr {
69    match expr {
70        Expr::Infix {
71            operator,
72            left,
73            right,
74        } => Expr::Call {
75            operator: Box::new(Expr::Symbol(operator)),
76            args: vec![resolve_operators(*left), resolve_operators(*right)],
77        },
78        Expr::Prefix { operator, arg } | Expr::Postfix { operator, arg } => Expr::Call {
79            operator: Box::new(Expr::Symbol(operator)),
80            args: vec![resolve_operators(*arg)],
81        },
82        Expr::Call { operator, args } => Expr::Call {
83            operator: Box::new(resolve_operators(*operator)),
84            args: args.into_iter().map(resolve_operators).collect(),
85        },
86        Expr::List(items) => Expr::List(items.into_iter().map(resolve_operators).collect()),
87        Expr::Vector(items) => Expr::Vector(items.into_iter().map(resolve_operators).collect()),
88        Expr::Set(items) => Expr::Set(items.into_iter().map(resolve_operators).collect()),
89        Expr::Block(items) => Expr::Block(items.into_iter().map(resolve_operators).collect()),
90        Expr::Map(entries) => Expr::Map(
91            entries
92                .into_iter()
93                .map(|(k, v)| (resolve_operators(k), resolve_operators(v)))
94                .collect(),
95        ),
96        Expr::Annotated { expr, annotations } => Expr::Annotated {
97            expr: Box::new(resolve_operators(*expr)),
98            annotations,
99        },
100        // Quote preserves its datum unevaluated; scalars, Local, and Extension
101        // carry no operator nodes.
102        other => other,
103    }
104}
105
106/// Run a recipe end to end against an [`EmptyCatalog`] (the legacy path): every
107/// required lib must already be loaded into `cx`, or the run errors.
108///
109/// Hard errors (missing requires, unknown codec, undecodable setup) return
110/// `Err`; an evaluation error is captured as `ok == false` with empty results so
111/// the caller still sees a `RecipeRun`.
112pub fn run_recipe(cx: &mut Cx, card: &RecipeCard) -> Result<RecipeRun> {
113    run_recipe_with_catalog(cx, &EmptyCatalog, card)
114}
115
116/// Run a recipe end to end, loading its `requires` from `catalog` first
117/// (COOKBOOK_7 requires-driven loading).
118///
119/// Before decode+eval the runner asks `catalog` to resolve each `requires` entry
120/// and loads the returned lib into the eval `Cx`, idempotently. A require the
121/// catalog does not carry (and that is not already loaded) makes the recipe a
122/// descriptor: the run returns `Err(Error::Eval("descriptor: requires <x> not in
123/// catalog"))`. This is what turns runnability into a structural property of
124/// (catalog + capability profile) rather than a hand-applied label.
125pub fn run_recipe_with_catalog(
126    cx: &mut Cx,
127    catalog: &dyn LibCatalog,
128    card: &RecipeCard,
129) -> Result<RecipeRun> {
130    require_eval_capability(cx)?;
131
132    let unresolved = load_requires(cx, catalog, card);
133    if !unresolved.is_empty() {
134        return Err(Error::Eval(format!(
135            "recipe {} descriptor: requires not in catalog: {}",
136            card.id,
137            unresolved.join(", ")
138        )));
139    }
140
141    let codec = Symbol::qualified("codec", card.codec.as_str());
142    let source = String::from_utf8(card.setup.clone())
143        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
144    // Decode to an evaluable TERM (per the codec's own lowering policy) so `(math/add
145    // 1.5 2.0)` becomes a call the runtime APPLIES -- not an inert data list. This is why
146    // recipes were historically limited to `(quote ...)` canaries: the old data-position
147    // decode turned a bare `(f x)` into a list, never a call, so nothing ran. `decode_term`
148    // lowers a lisp surface list to a call AND accepts a data codec's explicit call form
149    // (e.g. JSON's tagged `call`), so both codecs evaluate.
150    // Recipes are trusted embedded content; grant the read the capabilities their setup
151    // needs -- read-construct so `#(Class ...)` builds a real domain value (complex,
152    // tensor, rational, CAS), and read-eval for eval-position reader forms.
153    // `ReadPolicy::default()` grants neither, which is why domain recipes could only quote.
154    let read_policy = ReadPolicy {
155        trust: TrustLevel::TrustedSource,
156        capabilities: CapabilitySet::new()
157            .grant(read_construct_capability())
158            .grant(read_eval_capability()),
159    };
160    // Decode to an evaluable Expr, applying the codec's eval-surface lowering (a lisp
161    // `(f x)` becomes a call the runtime APPLIES) but WITHOUT the Term/Datum round-trip:
162    // that round-trip forces every list to a pure Datum and rejects a list that contains a
163    // call -- e.g. a `let` binding container `((x 5))` or a `match` clause -- so special
164    // forms could not run. Function-call recipes are unaffected (behaviour-identical).
165    let expr = resolve_operators(decode_eval_expr_with_codec(
166        cx,
167        &codec,
168        Input::Text(source),
169        read_policy,
170    )?);
171
172    let (results, eval_ok) = match cx.eval_expr(expr) {
173        Ok(value) => {
174            // Encode the computed value back with the recipe's own codec so a
175            // round-tripping surface (lisp, json, algol) reports on its own
176            // surface. A decode-only language codec (e.g. scheme-r7rs-small, which
177            // parses its surface but has no encoder) cannot render the result, so
178            // fall back to the canonical `codec/lisp` display -- the setup still
179            // parsed and evaluated on its own surface (COOK8.05).
180            let encoded = encode_value_with_codec(cx, &codec, &value, EncodeOptions::default())
181                .or_else(|_| {
182                    let lisp = Symbol::qualified("codec", "lisp");
183                    encode_value_with_codec(cx, &lisp, &value, EncodeOptions::default())
184                })?;
185            (vec![encoded.into_text()?], true)
186        }
187        Err(_) => (Vec::new(), false),
188    };
189
190    let mut checks = Vec::new();
191    let mut all_pass = true;
192    for expectation in &card.expect {
193        let actual = results.get(expectation.form).cloned();
194        let pass = actual.as_deref() == Some(expectation.result.as_str());
195        if !pass {
196            all_pass = false;
197        }
198        checks.push(CheckResult {
199            form: expectation.form,
200            expected: expectation.result.clone(),
201            actual: actual.unwrap_or_else(|| "<no such form>".to_string()),
202            pass,
203        });
204    }
205
206    Ok(RecipeRun {
207        recipe: card.id.clone(),
208        forms: results.len(),
209        results,
210        ok: eval_ok && all_pass,
211        checks,
212    })
213}
214
215/// Run a Category C recipe twice under the same (catalog + Cx) and confirm the
216/// two runs produce identical results -- the COOKBOOK_7 determinism guard.
217///
218/// Floating-point audio/FEM results drift across platforms, so Category C
219/// results are encoded as deterministic artifacts (digests, frames). Running the
220/// recipe twice and asserting the results match is a cheap, strong catch for an
221/// entropy or wall-clock leak: a non-deterministic recipe returns
222/// `Err(Error::Eval("... not deterministic ..."))`. The first run's `RecipeRun`
223/// is returned on success.
224pub fn run_recipe_twice(
225    cx: &mut Cx,
226    catalog: &dyn LibCatalog,
227    card: &RecipeCard,
228) -> Result<RecipeRun> {
229    let first = run_recipe_with_catalog(cx, catalog, card)?;
230    let second = run_recipe_with_catalog(cx, catalog, card)?;
231    if first.results != second.results {
232        return Err(Error::Eval(format!(
233            "recipe {} is not deterministic: {:?} != {:?}",
234            card.id, first.results, second.results
235        )));
236    }
237    Ok(first)
238}
239
240/// Decode a recipe's setup to an `Expr` without evaluating it (`cookbook:setup`).
241pub fn decode_setup(cx: &mut Cx, card: &RecipeCard) -> Result<sim_kernel::Expr> {
242    let codec = Symbol::qualified("codec", card.codec.as_str());
243    let source = String::from_utf8(card.setup.clone())
244        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
245    decode_with_codec(cx, &codec, Input::Text(source), ReadPolicy::default())
246}