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