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//! Running a recipe is capability-gated read-eval, so the runtime must hold the
5//! read-eval capability. Listing and showing recipes is not gated.
6//!
7//! Each setup decodes to a single top-level form. The data model carries a
8//! `Vec` of results and expectations, and this runner fills it from that
9//! decoded form without changing the stored recipe shape.
10
11use std::sync::Arc;
12
13use sim_codec::{
14    Input, decode_eval_expr_with_codec, decode_with_codec, encode_value_with_codec,
15    lower_operator_nodes,
16};
17use sim_cookbook::{CheckResult, RecipeCard, RecipeRun};
18use sim_kernel::{
19    CapabilityName, CapabilitySet, Cx, EncodeOptions, Error, Expr, ReadPolicy, Result, Shape,
20    Symbol, TrustLevel, read_construct_capability, read_eval_capability,
21};
22use sim_lib_core::{ReadEvalBroker, ReadEvalRequest, ReadEvalSource, RequestOrigin};
23use sim_shape::AnyShape;
24
25use crate::catalog::{CookbookCapabilityProfile, EmptyCatalog, LibCatalog, load_requires};
26
27/// Error unless the runtime holds the read-eval capability.
28pub fn require_eval_capability(cx: &Cx) -> Result<()> {
29    if cx.capabilities().contains(&read_eval_capability()) {
30        Ok(())
31    } else {
32        Err(Error::CapabilityDenied {
33            capability: read_eval_capability(),
34        })
35    }
36}
37
38/// Lib ids in `card.requires` that are not currently loaded.
39///
40/// A requirement matches a loaded lib by its fully qualified id
41/// (`namespace/name`) or by its unqualified `name` alone, so `numbers-f64`
42/// matches a lib whose id is `lisp/numbers-f64`.
43pub fn missing_requires(cx: &Cx, card: &RecipeCard) -> Vec<String> {
44    let loaded: Vec<(String, String)> = cx
45        .registry()
46        .libs()
47        .iter()
48        .map(|lib| {
49            (
50                lib.manifest.id.as_qualified_str(),
51                lib.manifest.id.name.to_string(),
52            )
53        })
54        .collect();
55    card.requires
56        .iter()
57        .filter(|req| {
58            !loaded
59                .iter()
60                .any(|(qualified, name)| qualified == *req || name == *req)
61        })
62        .cloned()
63        .collect()
64}
65
66/// Run a recipe end to end against an [`EmptyCatalog`] (the direct path): every
67/// required lib must already be loaded into `cx`, or the run errors.
68///
69/// Hard errors (missing requires, unknown codec, undecodable setup) return
70/// `Err`; an evaluation error is captured as `ok == false` with empty results so
71/// the caller still sees a `RecipeRun`.
72pub fn run_recipe(cx: &mut Cx, card: &RecipeCard) -> Result<RecipeRun> {
73    run_recipe_with_catalog(cx, &EmptyCatalog, card)
74}
75
76/// Run a recipe end to end, loading its `requires` from `catalog` first.
77///
78/// Before decode+eval the runner asks `catalog` to resolve each `requires` entry
79/// and loads the returned lib into the eval `Cx`, idempotently. A require the
80/// catalog does not carry (and that is not already loaded) makes the recipe a
81/// descriptor: the run returns `Err(Error::Eval("descriptor: requires <x> not in
82/// catalog"))`. This is what turns runnability into a structural property of
83/// (catalog + capability profile) rather than a hand-applied label.
84pub fn run_recipe_with_catalog(
85    cx: &mut Cx,
86    catalog: &dyn LibCatalog,
87    card: &RecipeCard,
88) -> Result<RecipeRun> {
89    run_recipe_with_catalog_shape(cx, catalog, card, recipe_result_shape())
90}
91
92fn run_recipe_with_catalog_shape(
93    cx: &mut Cx,
94    catalog: &dyn LibCatalog,
95    card: &RecipeCard,
96    expected_shape: Arc<dyn Shape>,
97) -> Result<RecipeRun> {
98    require_eval_capability(cx)?;
99
100    let unresolved = load_requires(cx, catalog, card);
101    if !unresolved.is_empty() {
102        return Err(Error::Eval(format!(
103            "recipe {} descriptor: requires not in catalog: {}",
104            card.id,
105            unresolved.join(", ")
106        )));
107    }
108
109    let codec = Symbol::qualified("codec", card.codec.as_str());
110    let source = String::from_utf8(card.setup.clone())
111        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
112    // Decode to an evaluable expression without a Term/Datum round-trip. The
113    // shared lowerer turns Algol-style operator nodes into calls while keeping
114    // Lisp special-form list containers structurally intact.
115    let expr = lower_operator_nodes(decode_eval_expr_with_codec(
116        cx,
117        &codec,
118        Input::Text(source),
119        trusted_recipe_read_policy(),
120    )?);
121
122    let request = recipe_read_eval_request(
123        card,
124        codec.clone(),
125        ReadEvalSource::Expr(expr),
126        expected_shape,
127    );
128    let broker = ReadEvalBroker::new();
129    let (results, eval_ok) = match broker.admit(cx, request) {
130        Ok(value) => {
131            // Encode the computed value back with the recipe's own codec so a
132            // round-tripping surface (lisp, json, algol) reports on its own
133            // surface. A decode-only language codec (e.g. scheme-r7rs-small, which
134            // parses its surface but has no encoder) cannot render the result, so
135            // fall back to the canonical `codec/lisp` display -- the setup still
136            // parsed and evaluated on its own surface.
137            let encoded = encode_value_with_codec(cx, &codec, &value, EncodeOptions::default())
138                .or_else(|_| {
139                    let lisp = Symbol::qualified("codec", "lisp");
140                    encode_value_with_codec(cx, &lisp, &value, EncodeOptions::default())
141                })?;
142            (vec![encoded.into_text()?], true)
143        }
144        Err(err) if is_hard_broker_error(&err) => return Err(err),
145        Err(_) => (Vec::new(), false),
146    };
147
148    let mut checks = Vec::new();
149    let mut all_pass = true;
150    for expectation in &card.expect {
151        let actual = results.get(expectation.form).cloned();
152        let pass = actual.as_deref() == Some(expectation.result.as_str());
153        if !pass {
154            all_pass = false;
155        }
156        checks.push(CheckResult {
157            form: expectation.form,
158            expected: expectation.result.clone(),
159            actual: actual.unwrap_or_else(|| "<no such form>".to_string()),
160            pass,
161        });
162    }
163
164    Ok(RecipeRun {
165        recipe: card.id.clone(),
166        forms: results.len(),
167        results,
168        ok: eval_ok && all_pass,
169        checks,
170    })
171}
172
173#[cfg(test)]
174pub(crate) fn run_recipe_with_catalog_for_shape_test(
175    cx: &mut Cx,
176    catalog: &dyn LibCatalog,
177    card: &RecipeCard,
178    expected_shape: Arc<dyn Shape>,
179) -> Result<RecipeRun> {
180    run_recipe_with_catalog_shape(cx, catalog, card, expected_shape)
181}
182
183fn recipe_read_eval_request(
184    card: &RecipeCard,
185    codec: Symbol,
186    source: ReadEvalSource,
187    expected_shape: Arc<dyn Shape>,
188) -> ReadEvalRequest {
189    ReadEvalRequest {
190        origin: RequestOrigin::with_detail(
191            Symbol::qualified("cookbook", "recipe"),
192            Expr::String(card.id.clone()),
193        ),
194        codec,
195        source,
196        read_policy: trusted_recipe_read_policy(),
197        requires: recipe_required_capabilities(card),
198        allow: recipe_allowed_capabilities(card),
199        expected_shape,
200    }
201}
202
203fn recipe_result_shape() -> Arc<dyn Shape> {
204    Arc::new(AnyShape)
205}
206
207fn trusted_recipe_read_policy() -> ReadPolicy {
208    ReadPolicy {
209        trust: TrustLevel::TrustedSource,
210        capabilities: CapabilitySet::new()
211            .grant(read_construct_capability())
212            .grant(read_eval_capability()),
213    }
214}
215
216fn recipe_required_capabilities(card: &RecipeCard) -> Vec<CapabilityName> {
217    let mut capabilities = vec![read_eval_capability()];
218    capabilities.extend(tagged_capabilities(card, "requires-capability:"));
219    sort_dedup_capabilities(&mut capabilities);
220    capabilities
221}
222
223fn recipe_allowed_capabilities(card: &RecipeCard) -> CapabilitySet {
224    let mut capabilities = tagged_capabilities(card, "allow-capability:");
225    if capabilities.is_empty() {
226        capabilities = CookbookCapabilityProfile::granted();
227    }
228    capabilities.extend(recipe_required_capabilities(card));
229    capabilities.push(read_construct_capability());
230    sort_dedup_capabilities(&mut capabilities);
231    capabilities
232        .into_iter()
233        .fold(CapabilitySet::new(), CapabilitySet::grant)
234}
235
236fn tagged_capabilities(card: &RecipeCard, prefix: &str) -> Vec<CapabilityName> {
237    card.tags
238        .iter()
239        .filter_map(|tag| {
240            let name = tag.strip_prefix(prefix)?;
241            (!name.is_empty()).then(|| CapabilityName::new(name.to_owned()))
242        })
243        .collect()
244}
245
246fn sort_dedup_capabilities(capabilities: &mut Vec<CapabilityName>) {
247    capabilities.sort();
248    capabilities.dedup();
249}
250
251fn is_hard_broker_error(err: &Error) -> bool {
252    matches!(
253        err,
254        Error::CapabilityDenied { .. }
255            | Error::TrustDenied { .. }
256            | Error::WrongShape { .. }
257            | Error::CodecError { .. }
258    )
259}
260
261/// Run a Category C recipe twice under the same (catalog + Cx) and confirm the
262/// two runs produce identical results.
263///
264/// Floating-point audio/FEM results drift across platforms, so Category C
265/// results are encoded as deterministic artifacts (digests, frames). Running the
266/// recipe twice and asserting the results match is a cheap, strong catch for an
267/// entropy or wall-clock leak: a non-deterministic recipe returns
268/// `Err(Error::Eval("... not deterministic ..."))`. The first run's `RecipeRun`
269/// is returned on success.
270pub fn run_recipe_twice(
271    cx: &mut Cx,
272    catalog: &dyn LibCatalog,
273    card: &RecipeCard,
274) -> Result<RecipeRun> {
275    let first = run_recipe_with_catalog(cx, catalog, card)?;
276    let second = run_recipe_with_catalog(cx, catalog, card)?;
277    if first.results != second.results {
278        return Err(Error::Eval(format!(
279            "recipe {} is not deterministic: {:?} != {:?}",
280            card.id, first.results, second.results
281        )));
282    }
283    Ok(first)
284}
285
286/// Decode a recipe's setup to an `Expr` without evaluating it (`cookbook:setup`).
287pub fn decode_setup(cx: &mut Cx, card: &RecipeCard) -> Result<sim_kernel::Expr> {
288    let codec = Symbol::qualified("codec", card.codec.as_str());
289    let source = String::from_utf8(card.setup.clone())
290        .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
291    decode_with_codec(cx, &codec, Input::Text(source), ReadPolicy::default())
292}