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 fork;
22mod fuzzy;
23mod llm;
24mod text;
25
26use crate::card::FileCardStore;
27use crate::llm_bridge::LlmRequest;
28use crate::state::JsonFileStore;
29use crate::variant_pkg::VariantPkg;
30
31/// Layer 1 prelude (also used by fork to setup child VMs).
32pub const PRELUDE: &str = include_str!("../prelude.lua");
33
34/// All handles needed by Layer 0 runtime primitives.
35///
36/// Collects the various per-session handles into a single config,
37/// avoiding a growing parameter list on `register()`.
38pub struct BridgeConfig {
39    /// Channel for LLM requests (None for eval_simple sessions).
40    pub llm_tx: Option<tokio::sync::mpsc::Sender<LlmRequest>>,
41    /// Namespace for alc.state (from ctx._ns or "default").
42    pub ns: String,
43    /// Custom metrics handle for alc.stats.record/get.
44    pub custom_metrics: CustomMetricsHandle,
45    /// Stats handle for `alc.stats.llm_calls()` (auto-counted session metrics).
46    pub stats: StatsHandle,
47    /// Budget checker for LLM call limits.
48    pub budget: BudgetHandle,
49    /// Progress reporter for alc.progress().
50    pub progress: ProgressHandle,
51    /// Package search paths (needed by alc.fork to setup child VMs).
52    pub lib_paths: Vec<PathBuf>,
53    /// Variant pkg overrides (`alc.local.toml`) — propagated to fork children.
54    pub variant_pkgs: Vec<VariantPkg>,
55    /// State store for `alc.state.*` (service layer resolves the root).
56    pub state_store: Arc<JsonFileStore>,
57    /// Card store for `alc.card.*` (service layer resolves the root).
58    pub card_store: Arc<FileCardStore>,
59    /// Scenarios directory exposed to Lua via `alc._dirs.scenarios`.
60    pub scenarios_dir: PathBuf,
61    /// Per-session log-capture ring buffer.
62    ///
63    /// Obtained from `ExecutionMetrics::log_sink_handle()`.  Passed to
64    /// `alc.log` and `print()` overrides so log output is routed into the
65    /// ring buffer for `alc_status` recent_logs.
66    ///
67    /// `None` for `eval_simple` / fork child sessions where observability
68    /// is not needed; in that case log entries are emitted to tracing only.
69    pub log_sink: Option<LogSink>,
70}
71
72pub use data::register_env;
73
74/// Register all Layer 0 runtime primitives onto the given table.
75pub fn register(lua: &Lua, alc_table: &LuaTable, config: BridgeConfig) -> LuaResult<()> {
76    data::register_json(lua, alc_table)?;
77    fuzzy::register_fuzzy(lua, alc_table)?;
78    // Register alc.log — pass LogSink when available so entries reach the ring buffer.
79    if let Some(sink) = config.log_sink.clone() {
80        data::register_log(lua, alc_table, sink.clone())?;
81        // Override global print() to also push to the ring buffer.
82        data::register_print(lua, sink)?;
83    } else {
84        // Fallback: tracing-only path for eval_simple / fork children.
85        data::register_log(lua, alc_table, algocline_core::LogSink::new())?;
86    }
87    data::register_state(lua, alc_table, config.ns, Arc::clone(&config.state_store))?;
88    data::register_card(lua, alc_table, Arc::clone(&config.card_store))?;
89    data::register_dirs(
90        lua,
91        alc_table,
92        config.state_store.root(),
93        config.card_store.root(),
94        &config.scenarios_dir,
95    )?;
96    text::register_chunk(lua, alc_table)?;
97    data::register_stats(lua, alc_table, config.custom_metrics, config.stats)?;
98    register_time(lua, alc_table)?;
99    register_math(lua, alc_table)?;
100    llm::register_budget_remaining(lua, alc_table, config.budget.clone())?;
101    llm::register_progress(lua, alc_table, config.progress)?;
102    if let Some(tx) = config.llm_tx {
103        llm::register_llm(lua, alc_table, tx.clone(), config.budget.clone())?;
104        llm::register_llm_batch(lua, alc_table, tx.clone(), config.budget.clone())?;
105        fork::register_fork(
106            lua,
107            alc_table,
108            tx,
109            config.budget,
110            config.lib_paths,
111            config.variant_pkgs,
112            config.state_store,
113            config.card_store,
114            config.scenarios_dir,
115        )?;
116    }
117    Ok(())
118}
119
120/// Register `alc.math` — mlua-mathlib v0.3 (RNG, distributions, statistics, hypothesis testing, ranking, information theory, time series).
121fn register_math(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
122    let math_table = mlua_mathlib::module(lua)?;
123    alc_table.set("math", math_table)?;
124    Ok(())
125}
126
127/// Embedded mock layer (`with_alc` / `alc_mock` / `alc.spy`) installed
128/// on top of the standard `alc.*` surface in `install_for_pkg_test`.
129pub(crate) const MOCK_LAYER: &str = include_str!("mock.lua");
130
131/// Install the production `alc.*` primitive surface plus the mock layer
132/// on `lua` for use by the `alc_pkg_test` sandbox.
133///
134/// Spec authors get:
135/// * the full `alc.*` surface that `alc_run` exposes (stateless helpers
136///   like `alc.json_encode`, `alc.fingerprint`, `alc.parse_number`,
137///   `alc.fuzzy.*`, plus stateful helpers backed by in-memory
138///   per-VM tempdirs for `alc.state.*` and `alc.card.*`);
139/// * `alc.llm` / `alc.llm_batch` / `alc.fork` as stubs that error out
140///   when called without a `with_alc({ llm = … }, …)` override;
141/// * a Pure-Lua mock layer (`with_alc(overrides, fn)`,
142///   `alc_mock.install/restore`, `alc.spy(name, default_fn?)`).
143///
144/// **Invariant** (enforced by `tests/bridge_sandbox_parity.rs`):
145/// `production primitive surface ⊆ test sandbox primitive surface`.
146/// Every key reachable on `_G.alc` after a successful production
147/// [`register`] call is also reachable after `install_for_pkg_test`.
148///
149/// The per-VM tempdir backing `state_store` / `card_store` is held on
150/// the Lua VM via `set_app_data` and dropped together with the VM.
151pub fn install_for_pkg_test(lua: &Lua) -> LuaResult<()> {
152    let metrics = ExecutionMetrics::new();
153    let tmp = TempDir::new()
154        .map_err(|e| LuaError::external(format!("install_for_pkg_test: tempdir: {e}")))?;
155    let root = tmp.path().to_path_buf();
156    // Tie tempdir lifetime to the Lua VM so it is cleaned up when the VM is dropped.
157    lua.set_app_data::<TempDir>(tmp);
158
159    let config = BridgeConfig {
160        llm_tx: None,
161        ns: "default".into(),
162        custom_metrics: metrics.custom_metrics_handle(),
163        stats: metrics.stats_handle(),
164        budget: metrics.budget_handle(),
165        progress: metrics.progress_handle(),
166        lib_paths: vec![],
167        variant_pkgs: vec![],
168        state_store: std::sync::Arc::new(crate::state::JsonFileStore::new(root.join("state"))),
169        card_store: std::sync::Arc::new(crate::card::FileCardStore::new(root.join("cards"))),
170        scenarios_dir: root.join("scenarios"),
171        log_sink: None,
172    };
173
174    let alc_table = lua.create_table()?;
175    register(lua, &alc_table, config)?;
176
177    // Stateful / external I/O entries that production-only registers when
178    // `llm_tx` is `Some`.  Install stubs so spec authors must mock them
179    // explicitly via `with_alc({ llm = ... }, fn)` — calling the unmocked
180    // entry surfaces a clear error instead of `attempt to call a nil value`.
181    install_external_io_stub(lua, &alc_table, "llm")?;
182    install_external_io_stub(lua, &alc_table, "llm_batch")?;
183    install_external_io_stub(lua, &alc_table, "fork")?;
184
185    lua.globals().set("alc", alc_table)?;
186    lua.load(PRELUDE)
187        .set_name("@alc_prelude")
188        .exec()
189        .map_err(|e| LuaError::external(format!("install_for_pkg_test: prelude: {e}")))?;
190    lua.load(MOCK_LAYER)
191        .set_name("@bridge_mock")
192        .exec()
193        .map_err(|e| LuaError::external(format!("install_for_pkg_test: mock layer: {e}")))?;
194    Ok(())
195}
196
197fn install_external_io_stub(lua: &Lua, alc_table: &LuaTable, name: &'static str) -> LuaResult<()> {
198    let stub = lua.create_function(
199        move |_, _: mlua::Variadic<LuaValue>| -> LuaResult<LuaValue> {
200            Err(LuaError::external(format!(
201                "mock required: alc.{name} — wrap the call in `with_alc({{ {name} = fn }}, fn)` \
202             inside your spec (alc_pkg_test sandbox stubs external I/O by design)"
203            )))
204        },
205    )?;
206    alc_table.set(name, stub)?;
207    Ok(())
208}
209
210/// Register `alc.time()` — wall-clock time in fractional seconds.
211///
212/// Lua usage:
213///   local start = alc.time()
214///   -- ... work ...
215///   local elapsed_secs = alc.time() - start
216///
217/// Returns: f64 seconds since Unix epoch (sub-millisecond precision).
218fn register_time(lua: &Lua, alc_table: &LuaTable) -> LuaResult<()> {
219    let time_fn = lua.create_function(|_, ()| {
220        let now = std::time::SystemTime::now()
221            .duration_since(std::time::UNIX_EPOCH)
222            .map_err(mlua::Error::external)?;
223        Ok(now.as_secs_f64())
224    })?;
225    alc_table.set("time", time_fn)?;
226    Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    use algocline_core::ExecutionMetrics;
234
235    fn test_config() -> BridgeConfig {
236        let metrics = ExecutionMetrics::new();
237        let tmp = tempfile::tempdir().expect("test tempdir");
238        let root = tmp.path().to_path_buf();
239        std::mem::forget(tmp);
240        BridgeConfig {
241            llm_tx: None,
242            ns: "default".into(),
243            custom_metrics: metrics.custom_metrics_handle(),
244            stats: metrics.stats_handle(),
245            budget: metrics.budget_handle(),
246            progress: metrics.progress_handle(),
247            lib_paths: vec![],
248            variant_pkgs: vec![],
249            state_store: Arc::new(JsonFileStore::new(root.join("state"))),
250            card_store: Arc::new(FileCardStore::new(root.join("cards"))),
251            scenarios_dir: root.join("scenarios"),
252            log_sink: None,
253        }
254    }
255
256    // ─── Prelude helpers ───
257
258    /// Setup Lua VM with Layer 0 bridge + Layer 1 prelude loaded.
259    fn setup_with_prelude() -> Lua {
260        let lua = Lua::new();
261        let t = lua.create_table().unwrap();
262        register(&lua, &t, test_config()).unwrap();
263        lua.globals().set("alc", t).unwrap();
264        lua.load(PRELUDE).exec().unwrap();
265        lua
266    }
267
268    // ─── alc.cache tests (non-LLM parts) ───
269
270    #[test]
271    fn cache_info_initial_state() {
272        let lua = setup_with_prelude();
273        let result: LuaValue = lua.load("return alc.cache_info()").eval().unwrap();
274        let tbl = result.as_table().unwrap();
275        assert_eq!(tbl.get::<i64>("entries").unwrap(), 0);
276        assert_eq!(tbl.get::<i64>("hits").unwrap(), 0);
277        assert_eq!(tbl.get::<i64>("misses").unwrap(), 0);
278    }
279
280    #[test]
281    fn cache_clear_resets_state() {
282        let lua = setup_with_prelude();
283        lua.load(
284            r#"
285            -- Simulate cache state by calling cache_info before/after clear
286            local info1 = alc.cache_info()
287            alc.cache_clear()
288            local info2 = alc.cache_info()
289            assert(info2.entries == 0)
290            assert(info2.hits == 0)
291            assert(info2.misses == 0)
292            "#,
293        )
294        .exec()
295        .unwrap();
296    }
297
298    // ─── alc.parallel tests (validation) ───
299
300    #[test]
301    fn parallel_rejects_empty_items() {
302        let lua = setup_with_prelude();
303        let result: Result<LuaValue, _> = lua
304            .load(r#"return alc.parallel({}, function(x) return x end)"#)
305            .eval();
306        let err = result.unwrap_err().to_string();
307        assert!(
308            err.contains("non-empty array"),
309            "expected non-empty array error, got: {err}"
310        );
311    }
312
313    #[test]
314    fn parallel_rejects_non_function_prompt_fn() {
315        let lua = setup_with_prelude();
316        let result: Result<LuaValue, _> = lua
317            .load(r#"return alc.parallel({"a", "b"}, "not a function")"#)
318            .eval();
319        let err = result.unwrap_err().to_string();
320        assert!(
321            err.contains("prompt_fn must be a function"),
322            "expected function error, got: {err}"
323        );
324    }
325
326    #[test]
327    fn parallel_rejects_invalid_prompt_fn_return() {
328        let lua = setup_with_prelude();
329        let result: Result<LuaValue, _> = lua
330            .load(r#"return alc.parallel({"a"}, function(x) return 42 end)"#)
331            .eval();
332        let err = result.unwrap_err().to_string();
333        assert!(
334            err.contains("must return string or table"),
335            "expected type error, got: {err}"
336        );
337    }
338
339    #[test]
340    fn parallel_rejects_table_without_prompt() {
341        let lua = setup_with_prelude();
342        let result: Result<LuaValue, _> = lua
343            .load(r#"return alc.parallel({"a"}, function(x) return { system = "hi" } end)"#)
344            .eval();
345        let err = result.unwrap_err().to_string();
346        assert!(
347            err.contains("without .prompt"),
348            "expected prompt field error, got: {err}"
349        );
350    }
351
352    // ─── alc.fingerprint tests (used by cache) ───
353
354    #[test]
355    fn fingerprint_deterministic() {
356        let lua = setup_with_prelude();
357        let result: bool = lua
358            .load(r#"return alc.fingerprint("hello") == alc.fingerprint("hello")"#)
359            .eval()
360            .unwrap();
361        assert!(result);
362    }
363
364    #[test]
365    fn fingerprint_normalized() {
366        let lua = setup_with_prelude();
367        let result: bool = lua
368            .load(r#"return alc.fingerprint("  Hello  World  ") == alc.fingerprint("hello world")"#)
369            .eval()
370            .unwrap();
371        assert!(result);
372    }
373
374    // ─── alc.parse_number tests ───
375
376    #[test]
377    fn parse_number_basic() {
378        let lua = setup_with_prelude();
379        let result: f64 = lua
380            .load(r#"return alc.parse_number("Found 3 subtasks to implement")"#)
381            .eval()
382            .unwrap();
383        assert!((result - 3.0).abs() < f64::EPSILON);
384    }
385
386    #[test]
387    fn parse_number_decimal() {
388        let lua = setup_with_prelude();
389        let result: f64 = lua
390            .load(r#"return alc.parse_number("Score: 7.5/10")"#)
391            .eval()
392            .unwrap();
393        assert!((result - 7.5).abs() < f64::EPSILON);
394    }
395
396    #[test]
397    fn parse_number_with_pattern() {
398        let lua = setup_with_prelude();
399        let result: f64 = lua
400            .load(r#"return alc.parse_number("Created 3 subtasks for implementation", "(%d+)%s+subtask")"#)
401            .eval()
402            .unwrap();
403        assert!((result - 3.0).abs() < f64::EPSILON);
404    }
405
406    #[test]
407    fn parse_number_nil_on_no_match() {
408        let lua = setup_with_prelude();
409        let result: LuaValue = lua
410            .load(r#"return alc.parse_number("no numbers here")"#)
411            .eval()
412            .unwrap();
413        assert!(result.is_nil());
414    }
415
416    #[test]
417    fn parse_number_negative() {
418        let lua = setup_with_prelude();
419        let result: f64 = lua
420            .load(r#"return alc.parse_number("Temperature: -5 degrees")"#)
421            .eval()
422            .unwrap();
423        assert!((result - (-5.0)).abs() < f64::EPSILON);
424    }
425}