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