Skip to main content

algocline_engine/bridge/
mod.rs

1//! Layer 0: Runtime Primitives
2//!
3//! Registers Rust-backed functions into the `alc.*` Lua namespace.
4//! These provide capabilities that cannot be expressed in Pure Lua:
5//! I/O (state), serialization (json), host communication (llm),
6//! and text processing (chunk).
7//!
8//! All functions registered here are available in every Lua session
9//! without explicit `require()`.
10
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use algocline_core::{
15    BudgetHandle, CustomMetricsHandle, ExecutionMetrics, LogSink, ProgressHandle, StatsHandle,
16};
17use mlua::prelude::*;
18use tempfile::TempDir;
19
20mod data;
21mod evalframe;
22mod fork;
23mod fuzzy;
24mod llm;
25#[cfg(feature = "nn")]
26mod nn_card;
27mod text;
28
29use crate::card::FileCardStore;
30use crate::llm_bridge::LlmRequest;
31use crate::state::JsonFileStore;
32use crate::variant_pkg::VariantPkg;
33
34/// Layer 1 prelude (also used by fork to setup child VMs).
35pub const PRELUDE: &str = include_str!("../prelude.lua");
36
37/// All handles needed by Layer 0 runtime primitives.
38///
39/// Collects the various per-session handles into a single config,
40/// avoiding a growing parameter list on `register()`.
41pub struct BridgeConfig {
42    /// Channel for LLM requests (None for eval_simple sessions).
43    pub llm_tx: Option<tokio::sync::mpsc::Sender<LlmRequest>>,
44    /// Namespace for alc.state (from ctx._ns or "default").
45    pub ns: String,
46    /// Custom metrics handle for alc.stats.record/get.
47    pub custom_metrics: CustomMetricsHandle,
48    /// Stats handle for `alc.stats.llm_calls()` (auto-counted session metrics).
49    pub stats: StatsHandle,
50    /// Budget checker for LLM call limits.
51    pub budget: BudgetHandle,
52    /// Progress reporter for alc.progress().
53    pub progress: ProgressHandle,
54    /// Package search paths (needed by alc.fork to setup child VMs).
55    pub lib_paths: Vec<PathBuf>,
56    /// Variant pkg overrides (`alc.local.toml`) — propagated to fork children.
57    pub variant_pkgs: Vec<VariantPkg>,
58    /// State store for `alc.state.*` (service layer resolves the root).
59    pub state_store: Arc<JsonFileStore>,
60    /// Card store for `alc.card.*` (service layer resolves the root).
61    pub card_store: Arc<FileCardStore>,
62    /// Cached `[setting.card].run` value resolved at session start.
63    ///
64    /// When `false` (default), `alc.card.create` / `alc.card.append` calls
65    /// that carry a top-level `run` field become no-op: the closures return
66    /// Lua `nil` without touching the card store or publishing a
67    /// `CardEvent`.  Calls without a `run` field are unaffected (Phase 1-B
68    /// gate is additive — existing pkgs that never populate `run` see no
69    /// behavioural change).
70    pub card_run_enabled: bool,
71    /// Scenarios directory exposed to Lua via `alc._dirs.scenarios`.
72    pub scenarios_dir: PathBuf,
73    /// Store root for `alc.nn` model bundles.
74    ///
75    /// Consumed by `register_nn` under the `nn` feature to construct the
76    /// filesystem-backed [`algocline_nn::FsStore`] wired via
77    /// [`algocline_nn::install_store`]. Resolved by the service layer via
78    /// [`algocline_core::AppDir::nn_dir`] so the engine crate never touches
79    /// `$HOME` / `$ALC_HOME` directly.
80    ///
81    /// An empty [`PathBuf`] means "do not install a store" — `alc.nn.save` /
82    /// `alc.nn.load` then error with `"no NN store registered"`. Test sites
83    /// that never exercise save/load can pass [`PathBuf::new`]. Ignored when
84    /// the `nn` feature is disabled.
85    pub nn_dir: PathBuf,
86    /// Per-session log-capture ring buffer.
87    ///
88    /// Obtained from `ExecutionMetrics::log_sink_handle()`.  Passed to
89    /// `alc.log` and `print()` overrides so log output is routed into the
90    /// ring buffer for `alc_status` recent_logs.
91    ///
92    /// `None` for `eval_simple` / fork child sessions where observability
93    /// is not needed; in that case log entries are emitted to tracing only.
94    pub log_sink: Option<LogSink>,
95}
96
97pub use data::register_env;
98pub use evalframe::register_evalframe;
99
100/// Register all Layer 0 runtime primitives onto the given table.
101pub fn register(lua: &Lua, alc_table: &LuaTable, config: BridgeConfig) -> LuaResult<()> {
102    data::register_json(lua, alc_table)?;
103    fuzzy::register_fuzzy(lua, alc_table)?;
104    // Register alc.log — pass LogSink when available so entries reach the ring buffer.
105    if let Some(sink) = config.log_sink.clone() {
106        data::register_log(lua, alc_table, sink.clone())?;
107        // Override global print() to also push to the ring buffer.
108        data::register_print(lua, sink)?;
109    } else {
110        // Fallback: tracing-only path for eval_simple / fork children.
111        data::register_log(lua, alc_table, algocline_core::LogSink::new())?;
112    }
113    data::register_state(lua, alc_table, config.ns, Arc::clone(&config.state_store))?;
114    data::register_card(
115        lua,
116        alc_table,
117        Arc::clone(&config.card_store),
118        config.card_run_enabled,
119    )?;
120    data::register_dirs(
121        lua,
122        alc_table,
123        config.state_store.root(),
124        config.card_store.root(),
125        &config.scenarios_dir,
126    )?;
127    text::register_chunk(lua, alc_table)?;
128    data::register_stats(lua, alc_table, config.custom_metrics, config.stats)?;
129    register_time(lua, alc_table)?;
130    register_math(lua, alc_table)?;
131    #[cfg(feature = "nn")]
132    register_nn(lua, alc_table, config.nn_dir.clone())?;
133    #[cfg(feature = "nn")]
134    nn_card::register_nn_card(
135        lua,
136        alc_table,
137        Arc::clone(&config.card_store),
138        config.nn_dir.clone(),
139    )?;
140    llm::register_budget_remaining(lua, alc_table, config.budget.clone())?;
141    llm::register_progress(lua, alc_table, config.progress)?;
142    if let Some(tx) = config.llm_tx {
143        llm::register_llm(
144            lua,
145            alc_table,
146            tx.clone(),
147            config.budget.clone(),
148            Arc::clone(&config.card_store),
149        )?;
150        llm::register_llm_batch(lua, alc_table, tx.clone(), config.budget.clone())?;
151        fork::register_fork(
152            lua,
153            alc_table,
154            tx,
155            config.budget,
156            config.lib_paths,
157            config.variant_pkgs,
158            config.state_store,
159            config.card_store,
160            config.card_run_enabled,
161            config.scenarios_dir,
162            config.nn_dir,
163        )?;
164    }
165    Ok(())
166}
167
168/// Register `alc.math` — mlua-mathlib v0.3 (RNG, distributions, statistics, hypothesis testing, ranking, information theory, time series).
169fn register_math(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
170    let math_table = mlua_mathlib::module(lua)?;
171    alc_table.set("math", math_table)?;
172    Ok(())
173}
174
175/// Register `alc.nn` — thin candle wrapper (feature `nn`, default off).
176///
177/// Only compiled when the `nn` feature is enabled, so the default build never
178/// links candle. Mirrors [`register_math`]: delegate table construction to the
179/// dedicated bridge crate and set it as `alc.nn`.
180///
181/// When `nn_dir` is non-empty, an [`algocline_nn::FsStore`] rooted there is
182/// installed on the VM so `alc.nn.save` / `alc.nn.load` resolve through it.
183/// An empty `nn_dir` skips the install; save/load then error with
184/// `"no NN store registered"` — suitable for test VMs that never exercise
185/// persistence. The path itself is resolved by the service layer via
186/// [`algocline_core::AppDir::nn_dir`]; the engine crate stays free of any
187/// `$HOME` / `$ALC_HOME` reads.
188#[cfg(feature = "nn")]
189fn register_nn(lua: &Lua, alc_table: &LuaTable, nn_dir: PathBuf) -> LuaResult<()> {
190    let nn_table = algocline_nn::module(lua)?;
191    alc_table.set("nn", nn_table)?;
192    if !nn_dir.as_os_str().is_empty() {
193        algocline_nn::install_store(lua, Arc::new(algocline_nn::FsStore::new(nn_dir)))?;
194    }
195    Ok(())
196}
197
198/// Embedded mock layer (`with_alc` / `alc_mock` / `alc.spy`) installed
199/// on top of the standard `alc.*` surface in `install_for_pkg_test`.
200pub(crate) const MOCK_LAYER: &str = include_str!("mock.lua");
201
202/// Install the production `alc.*` primitive surface plus the mock layer
203/// on `lua` for use by the `alc_pkg_test` sandbox.
204///
205/// Spec authors get:
206/// * the full `alc.*` surface that `alc_run` exposes (stateless helpers
207///   like `alc.json_encode`, `alc.fingerprint`, `alc.parse_number`,
208///   `alc.fuzzy.*`, plus stateful helpers backed by in-memory
209///   per-VM tempdirs for `alc.state.*` and `alc.card.*`);
210/// * `alc.llm` / `alc.llm_batch` / `alc.fork` as stubs that error out
211///   when called without a `with_alc({ llm = … }, …)` override;
212/// * a Pure-Lua mock layer (`with_alc(overrides, fn)`,
213///   `alc_mock.install/restore`, `alc.spy(name, default_fn?)`).
214///
215/// **Invariant** (enforced by `tests/bridge_sandbox_parity.rs`):
216/// `production primitive surface ⊆ test sandbox primitive surface`.
217/// Every key reachable on `_G.alc` after a successful production
218/// [`register`] call is also reachable after `install_for_pkg_test`.
219///
220/// The per-VM tempdir backing `state_store` / `card_store` is held on
221/// the Lua VM via `set_app_data` and dropped together with the VM.
222pub fn install_for_pkg_test(lua: &Lua) -> LuaResult<()> {
223    let metrics = ExecutionMetrics::new();
224    let tmp = TempDir::new()
225        .map_err(|e| LuaError::external(format!("install_for_pkg_test: tempdir: {e}")))?;
226    let root = tmp.path().to_path_buf();
227    // Tie tempdir lifetime to the Lua VM so it is cleaned up when the VM is dropped.
228    lua.set_app_data::<TempDir>(tmp);
229
230    let config = BridgeConfig {
231        llm_tx: None,
232        ns: "default".into(),
233        custom_metrics: metrics.custom_metrics_handle(),
234        stats: metrics.stats_handle(),
235        budget: metrics.budget_handle(),
236        progress: metrics.progress_handle(),
237        lib_paths: vec![],
238        variant_pkgs: vec![],
239        state_store: std::sync::Arc::new(crate::state::JsonFileStore::new(root.join("state"))),
240        card_store: std::sync::Arc::new(crate::card::FileCardStore::new(root.join("cards"))),
241        // Enable the `[run]` gate by default in pkg-test sandboxes so
242        // spec authors can exercise the new field without extra plumbing.
243        // Production sessions receive the resolved `[setting.card].run`
244        // value from the service layer.
245        card_run_enabled: true,
246        scenarios_dir: root.join("scenarios"),
247        nn_dir: root.join("nn"),
248        log_sink: None,
249    };
250
251    let alc_table = lua.create_table()?;
252    register(lua, &alc_table, config)?;
253
254    // Stateful / external I/O entries that production-only registers when
255    // `llm_tx` is `Some`.  Install stubs so spec authors must mock them
256    // explicitly via `with_alc({ llm = ... }, fn)` — calling the unmocked
257    // entry surfaces a clear error instead of `attempt to call a nil value`.
258    install_external_io_stub(lua, &alc_table, "llm")?;
259    install_external_io_stub(lua, &alc_table, "llm_batch")?;
260    install_external_io_stub(lua, &alc_table, "fork")?;
261
262    lua.globals().set("alc", alc_table)?;
263    evalframe::register_evalframe(lua).map_err(|e| {
264        LuaError::external(format!("install_for_pkg_test: register_evalframe: {e}"))
265    })?;
266    lua.load(PRELUDE)
267        .set_name("@alc_prelude")
268        .exec()
269        .map_err(|e| LuaError::external(format!("install_for_pkg_test: prelude: {e}")))?;
270    lua.load(MOCK_LAYER)
271        .set_name("@bridge_mock")
272        .exec()
273        .map_err(|e| LuaError::external(format!("install_for_pkg_test: mock layer: {e}")))?;
274    Ok(())
275}
276
277fn install_external_io_stub(lua: &Lua, alc_table: &LuaTable, name: &'static str) -> LuaResult<()> {
278    let stub = lua.create_function(
279        move |_, _: mlua::Variadic<LuaValue>| -> LuaResult<LuaValue> {
280            Err(LuaError::external(format!(
281                "mock required: alc.{name} — wrap the call in `with_alc({{ {name} = fn }}, fn)` \
282             inside your spec (alc_pkg_test sandbox stubs external I/O by design)"
283            )))
284        },
285    )?;
286    alc_table.set(name, stub)?;
287    Ok(())
288}
289
290/// Register `alc.time()` — wall-clock time in fractional seconds.
291///
292/// Lua usage:
293///   local start = alc.time()
294///   -- ... work ...
295///   local elapsed_secs = alc.time() - start
296///
297/// Returns: f64 seconds since Unix epoch (sub-millisecond precision).
298fn register_time(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
299    let time_fn = lua.create_function(|_, ()| {
300        let now = std::time::SystemTime::now()
301            .duration_since(std::time::UNIX_EPOCH)
302            .map_err(mlua::Error::external)?;
303        Ok(now.as_secs_f64())
304    })?;
305    alc_table.set("time", time_fn)?;
306    Ok(())
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    use algocline_core::ExecutionMetrics;
314
315    fn test_config() -> BridgeConfig {
316        let metrics = ExecutionMetrics::new();
317        let tmp = tempfile::tempdir().expect("test tempdir");
318        let root = tmp.path().to_path_buf();
319        std::mem::forget(tmp);
320        BridgeConfig {
321            llm_tx: None,
322            ns: "default".into(),
323            custom_metrics: metrics.custom_metrics_handle(),
324            stats: metrics.stats_handle(),
325            budget: metrics.budget_handle(),
326            progress: metrics.progress_handle(),
327            lib_paths: vec![],
328            variant_pkgs: vec![],
329            state_store: Arc::new(JsonFileStore::new(root.join("state"))),
330            card_store: Arc::new(FileCardStore::new(root.join("cards"))),
331            card_run_enabled: false,
332            scenarios_dir: root.join("scenarios"),
333            nn_dir: root.join("nn"),
334            log_sink: None,
335        }
336    }
337
338    // ─── Prelude helpers ───
339
340    /// Setup Lua VM with Layer 0 bridge + Layer 1 prelude loaded.
341    fn setup_with_prelude() -> Lua {
342        let lua = Lua::new();
343        let t = lua.create_table().unwrap();
344        register(&lua, &t, test_config()).unwrap();
345        lua.globals().set("alc", t).unwrap();
346        lua.load(PRELUDE).exec().unwrap();
347        lua
348    }
349
350    // ─── alc.cache tests (non-LLM parts) ───
351
352    #[test]
353    fn cache_info_initial_state() {
354        let lua = setup_with_prelude();
355        let result: LuaValue = lua.load("return alc.cache_info()").eval().unwrap();
356        let tbl = result.as_table().unwrap();
357        assert_eq!(tbl.get::<i64>("entries").unwrap(), 0);
358        assert_eq!(tbl.get::<i64>("hits").unwrap(), 0);
359        assert_eq!(tbl.get::<i64>("misses").unwrap(), 0);
360    }
361
362    #[test]
363    fn cache_clear_resets_state() {
364        let lua = setup_with_prelude();
365        lua.load(
366            r#"
367            -- Simulate cache state by calling cache_info before/after clear
368            local info1 = alc.cache_info()
369            alc.cache_clear()
370            local info2 = alc.cache_info()
371            assert(info2.entries == 0)
372            assert(info2.hits == 0)
373            assert(info2.misses == 0)
374            "#,
375        )
376        .exec()
377        .unwrap();
378    }
379
380    // ─── alc.parallel tests (validation) ───
381
382    #[test]
383    fn parallel_rejects_empty_items() {
384        let lua = setup_with_prelude();
385        let result: Result<LuaValue, _> = lua
386            .load(r#"return alc.parallel({}, function(x) return x end)"#)
387            .eval();
388        let err = result.unwrap_err().to_string();
389        assert!(
390            err.contains("non-empty array"),
391            "expected non-empty array error, got: {err}"
392        );
393    }
394
395    #[test]
396    fn parallel_rejects_non_function_prompt_fn() {
397        let lua = setup_with_prelude();
398        let result: Result<LuaValue, _> = lua
399            .load(r#"return alc.parallel({"a", "b"}, "not a function")"#)
400            .eval();
401        let err = result.unwrap_err().to_string();
402        assert!(
403            err.contains("prompt_fn must be a function"),
404            "expected function error, got: {err}"
405        );
406    }
407
408    #[test]
409    fn parallel_rejects_invalid_prompt_fn_return() {
410        let lua = setup_with_prelude();
411        let result: Result<LuaValue, _> = lua
412            .load(r#"return alc.parallel({"a"}, function(x) return 42 end)"#)
413            .eval();
414        let err = result.unwrap_err().to_string();
415        assert!(
416            err.contains("must return string or table"),
417            "expected type error, got: {err}"
418        );
419    }
420
421    #[test]
422    fn parallel_rejects_table_without_prompt() {
423        let lua = setup_with_prelude();
424        let result: Result<LuaValue, _> = lua
425            .load(r#"return alc.parallel({"a"}, function(x) return { system = "hi" } end)"#)
426            .eval();
427        let err = result.unwrap_err().to_string();
428        assert!(
429            err.contains("without .prompt"),
430            "expected prompt field error, got: {err}"
431        );
432    }
433
434    // ─── alc.fingerprint tests (used by cache) ───
435
436    #[test]
437    fn fingerprint_deterministic() {
438        let lua = setup_with_prelude();
439        let result: bool = lua
440            .load(r#"return alc.fingerprint("hello") == alc.fingerprint("hello")"#)
441            .eval()
442            .unwrap();
443        assert!(result);
444    }
445
446    #[test]
447    fn fingerprint_normalized() {
448        let lua = setup_with_prelude();
449        let result: bool = lua
450            .load(r#"return alc.fingerprint("  Hello  World  ") == alc.fingerprint("hello world")"#)
451            .eval()
452            .unwrap();
453        assert!(result);
454    }
455
456    // ─── alc.parse_number tests ───
457
458    #[test]
459    fn parse_number_basic() {
460        let lua = setup_with_prelude();
461        let result: f64 = lua
462            .load(r#"return alc.parse_number("Found 3 subtasks to implement")"#)
463            .eval()
464            .unwrap();
465        assert!((result - 3.0).abs() < f64::EPSILON);
466    }
467
468    #[test]
469    fn parse_number_decimal() {
470        let lua = setup_with_prelude();
471        let result: f64 = lua
472            .load(r#"return alc.parse_number("Score: 7.5/10")"#)
473            .eval()
474            .unwrap();
475        assert!((result - 7.5).abs() < f64::EPSILON);
476    }
477
478    #[test]
479    fn parse_number_with_pattern() {
480        let lua = setup_with_prelude();
481        let result: f64 = lua
482            .load(r#"return alc.parse_number("Created 3 subtasks for implementation", "(%d+)%s+subtask")"#)
483            .eval()
484            .unwrap();
485        assert!((result - 3.0).abs() < f64::EPSILON);
486    }
487
488    #[test]
489    fn parse_number_nil_on_no_match() {
490        let lua = setup_with_prelude();
491        let result: LuaValue = lua
492            .load(r#"return alc.parse_number("no numbers here")"#)
493            .eval()
494            .unwrap();
495        assert!(result.is_nil());
496    }
497
498    #[test]
499    fn parse_number_negative() {
500        let lua = setup_with_prelude();
501        let result: f64 = lua
502            .load(r#"return alc.parse_number("Temperature: -5 degrees")"#)
503            .eval()
504            .unwrap();
505        assert!((result - (-5.0)).abs() < f64::EPSILON);
506    }
507}