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