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, 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/// Run a recipe end to end against an [`EmptyCatalog`] (the legacy path): every
61/// required lib must already be loaded into `cx`, or the run errors.
62///
63/// Hard errors (missing requires, unknown codec, undecodable setup) return
64/// `Err`; an evaluation error is captured as `ok == false` with empty results so
65/// the caller still sees a `RecipeRun`.
66pub fn run_recipe(cx: &mut Cx, card: &RecipeCard) -> Result<RecipeRun> {
67 run_recipe_with_catalog(cx, &EmptyCatalog, card)
68}
69
70/// Run a recipe end to end, loading its `requires` from `catalog` first
71/// (COOKBOOK_7 requires-driven loading).
72///
73/// Before decode+eval the runner asks `catalog` to resolve each `requires` entry
74/// and loads the returned lib into the eval `Cx`, idempotently. A require the
75/// catalog does not carry (and that is not already loaded) makes the recipe a
76/// descriptor: the run returns `Err(Error::Eval("descriptor: requires <x> not in
77/// catalog"))`. This is what turns runnability into a structural property of
78/// (catalog + capability profile) rather than a hand-applied label.
79pub fn run_recipe_with_catalog(
80 cx: &mut Cx,
81 catalog: &dyn LibCatalog,
82 card: &RecipeCard,
83) -> Result<RecipeRun> {
84 require_eval_capability(cx)?;
85
86 let unresolved = load_requires(cx, catalog, card);
87 if !unresolved.is_empty() {
88 return Err(Error::Eval(format!(
89 "recipe {} descriptor: requires not in catalog: {}",
90 card.id,
91 unresolved.join(", ")
92 )));
93 }
94
95 let codec = Symbol::qualified("codec", card.codec.as_str());
96 let source = String::from_utf8(card.setup.clone())
97 .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
98 // Decode to an evaluable TERM (per the codec's own lowering policy) so `(math/add
99 // 1.5 2.0)` becomes a call the runtime APPLIES -- not an inert data list. This is why
100 // recipes were historically limited to `(quote ...)` canaries: the old data-position
101 // decode turned a bare `(f x)` into a list, never a call, so nothing ran. `decode_term`
102 // lowers a lisp surface list to a call AND accepts a data codec's explicit call form
103 // (e.g. JSON's tagged `call`), so both codecs evaluate.
104 // Recipes are trusted embedded content; grant the read the capabilities their setup
105 // needs -- read-construct so `#(Class ...)` builds a real domain value (complex,
106 // tensor, rational, CAS), and read-eval for eval-position reader forms.
107 // `ReadPolicy::default()` grants neither, which is why domain recipes could only quote.
108 let read_policy = ReadPolicy {
109 trust: TrustLevel::TrustedSource,
110 capabilities: CapabilitySet::new()
111 .grant(read_construct_capability())
112 .grant(read_eval_capability()),
113 };
114 // Decode to an evaluable Expr, applying the codec's eval-surface lowering (a lisp
115 // `(f x)` becomes a call the runtime APPLIES) but WITHOUT the Term/Datum round-trip:
116 // that round-trip forces every list to a pure Datum and rejects a list that contains a
117 // call -- e.g. a `let` binding container `((x 5))` or a `match` clause -- so special
118 // forms could not run. Function-call recipes are unaffected (behaviour-identical).
119 let expr = decode_eval_expr_with_codec(cx, &codec, Input::Text(source), read_policy)?;
120
121 let (results, eval_ok) = match cx.eval_expr(expr) {
122 Ok(value) => {
123 let text = encode_value_with_codec(cx, &codec, &value, EncodeOptions::default())?
124 .into_text()?;
125 (vec![text], true)
126 }
127 Err(_) => (Vec::new(), false),
128 };
129
130 let mut checks = Vec::new();
131 let mut all_pass = true;
132 for expectation in &card.expect {
133 let actual = results.get(expectation.form).cloned();
134 let pass = actual.as_deref() == Some(expectation.result.as_str());
135 if !pass {
136 all_pass = false;
137 }
138 checks.push(CheckResult {
139 form: expectation.form,
140 expected: expectation.result.clone(),
141 actual: actual.unwrap_or_else(|| "<no such form>".to_string()),
142 pass,
143 });
144 }
145
146 Ok(RecipeRun {
147 recipe: card.id.clone(),
148 forms: results.len(),
149 results,
150 ok: eval_ok && all_pass,
151 checks,
152 })
153}
154
155/// Run a Category C recipe twice under the same (catalog + Cx) and confirm the
156/// two runs produce identical results -- the COOKBOOK_7 determinism guard.
157///
158/// Floating-point audio/FEM results drift across platforms, so Category C
159/// results are encoded as deterministic artifacts (digests, frames). Running the
160/// recipe twice and asserting the results match is a cheap, strong catch for an
161/// entropy or wall-clock leak: a non-deterministic recipe returns
162/// `Err(Error::Eval("... not deterministic ..."))`. The first run's `RecipeRun`
163/// is returned on success.
164pub fn run_recipe_twice(
165 cx: &mut Cx,
166 catalog: &dyn LibCatalog,
167 card: &RecipeCard,
168) -> Result<RecipeRun> {
169 let first = run_recipe_with_catalog(cx, catalog, card)?;
170 let second = run_recipe_with_catalog(cx, catalog, card)?;
171 if first.results != second.results {
172 return Err(Error::Eval(format!(
173 "recipe {} is not deterministic: {:?} != {:?}",
174 card.id, first.results, second.results
175 )));
176 }
177 Ok(first)
178}
179
180/// Decode a recipe's setup to an `Expr` without evaluating it (`cookbook:setup`).
181pub fn decode_setup(cx: &mut Cx, card: &RecipeCard) -> Result<sim_kernel::Expr> {
182 let codec = Symbol::qualified("codec", card.codec.as_str());
183 let source = String::from_utf8(card.setup.clone())
184 .map_err(|e| Error::Eval(format!("recipe {} setup is not UTF-8: {e}", card.id)))?;
185 decode_with_codec(cx, &codec, Input::Text(source), ReadPolicy::default())
186}