From 506afe6a3557957e60c5027ae80237015989bbfd Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Tue, 12 May 2026 16:04:46 +0100 Subject: [PATCH 1/8] Introduce example `struct-support` --- Cargo.lock | 9 ++++ Cargo.toml | 1 + examples/struct-support/Cargo.toml | 15 +++++++ examples/struct-support/src/main.rs | 69 +++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+) create mode 100644 examples/struct-support/Cargo.toml create mode 100644 examples/struct-support/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index a03ed52..50f270a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2910,6 +2910,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "struct-support-example" +version = "0.1.0" +dependencies = [ + "symbiont", + "tokio", + "tracing", +] + [[package]] name = "subtle" version = "2.6.1" diff --git a/Cargo.toml b/Cargo.toml index d291801..67a365a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "examples/quantize", "examples/rastrigin", "examples/sort", + "examples/struct-support", "examples/tictactoe", "symbiont", "symbiont-macros", diff --git a/examples/struct-support/Cargo.toml b/examples/struct-support/Cargo.toml new file mode 100644 index 0000000..9d1996f --- /dev/null +++ b/examples/struct-support/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition = "2024" +license = "MPL-2.0" +name = "struct-support-example" +publish = false +version = "0.1.0" + +[lints] +workspace = true + +[dependencies] +symbiont = { path = "../../symbiont" } + +tokio.workspace = true +tracing.workspace = true diff --git a/examples/struct-support/src/main.rs b/examples/struct-support/src/main.rs new file mode 100644 index 0000000..bcb00a3 --- /dev/null +++ b/examples/struct-support/src/main.rs @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: MPL-2.0 +//! The example shows support for functions taking in custom structs from the surrounding scope. + +use std::time::Duration; + +use symbiont::Runtime; +use tracing::info; + +/// A 2D game state, with just the x and y coordinates. +#[derive(Debug, Clone)] +#[allow(dead_code, reason = "Just debug impl is used.")] +struct GameState { + /// The x coodinate. Range of 0..100 + x: usize, + /// The y coordinate. Range of 0..250 + y: usize, +} + +symbiont::evolvable! { + /// Implement some different logic in here, while respecting the bounds laid out in the docs. + fn step(state: &mut GameState) { + if state.x < 100 { + state.x += 1; + } + if state.y < 250 { + state.x += 1; + } + } +} + +#[tokio::main] +async fn main() -> symbiont::Result<()> { + symbiont::init_tracing(); + + info!("SYMBIONT_DECLS: {SYMBIONT_DECLS:#?}"); + let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; + let fn_source = runtime.fn_full_sources(); + info!("fn_source: {fn_source:?}"); + + let agent = symbiont::inference::init_agent()?; + + let base_prompt = format!( + "Give an implementation for this function: ```{}```, \ + Give Rust Code Only.", + fn_source[0] + ); + + let mut last_evolution = std::time::Instant::now(); + let evolution_interval = Duration::from_secs(5); + + let mut state = GameState { x: 0, y: 0 }; + + loop { + step(&mut state); + println!("state: {state:?}"); + std::thread::sleep(Duration::from_secs(1)); + + if last_evolution.elapsed() >= evolution_interval { + runtime + .evolve(&agent, &base_prompt) + .await + .expect("Can successfully evolve"); + info!( + "Successfully evolved the function, which is now hot-reloaded in-place. Next call to `step` will run the newly compiled Agent code." + ); + last_evolution = std::time::Instant::now(); + } + } +} From 593dc80ddae88e78333c620e7ebcbdcd65e40c48 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Tue, 12 May 2026 16:08:29 +0100 Subject: [PATCH 2/8] 0.8: implement support for custom structs being passed in to `evolvable!` functions --- Cargo.lock | 4 +- README.md | 2 +- examples/counter/src/main.rs | 7 +- examples/fizzbuzz/src/main.rs | 2 +- examples/quantize/src/main.rs | 3 +- examples/rastrigin/src/main.rs | 2 +- examples/sort/src/main.rs | 3 +- examples/struct-support/src/main.rs | 49 +++++++----- examples/tictactoe/src/main.rs | 2 +- symbiont-macros/Cargo.toml | 2 +- symbiont-macros/src/evolvable.rs | 64 ++++++++++++++-- symbiont-macros/src/lib.rs | 103 ++++++++++++++++++++++++++ symbiont/Cargo.toml | 4 +- symbiont/benches/dispatch_overhead.rs | 2 +- symbiont/src/lib.rs | 5 +- symbiont/src/runtime.rs | 44 ++++++++++- symbiont/src/utils.rs | 12 ++- symbiont/tests/runtime.rs | 2 +- 18 files changed, 269 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 50f270a..51ed1e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2927,7 +2927,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbiont" -version = "0.7.0" +version = "0.8.0" dependencies = [ "criterion", "libloading", @@ -2947,7 +2947,7 @@ dependencies = [ [[package]] name = "symbiont-macros" -version = "0.7.0" +version = "0.8.0" dependencies = [ "prettyplease", "proc-macro2", diff --git a/README.md b/README.md index 089b659..128ef1c 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ symbiont::evolvable! { #[tokio::main] async fn main() -> symbiont::Result<()> { - let runtime = symbiont::Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; + let runtime = symbiont::Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Debug).await?; let agent = symbiont::inference::init_agent()?; let fn_sigs = runtime.fn_sigs(); let base_prompt = format!( diff --git a/examples/counter/src/main.rs b/examples/counter/src/main.rs index 2e46124..e6d3426 100644 --- a/examples/counter/src/main.rs +++ b/examples/counter/src/main.rs @@ -28,7 +28,7 @@ async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); info!("SYMBIONT_DECLS: {SYMBIONT_DECLS:#?}"); - let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; + let runtime = Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Debug).await?; let fn_sigs = runtime.fn_sigs(); // Alternatively, `fn_full_sources` can be used to also show doc string and default function body. info!("fn_sigs: {fn_sigs:?}"); @@ -45,7 +45,7 @@ async fn main() -> symbiont::Result<()> { let mut last_evolution = std::time::Instant::now(); let evolution_interval = Duration::from_secs(5); - loop { + for _ in 0..10 { step(&mut counter); println!("counter: {counter}"); std::thread::sleep(Duration::from_secs(1)); @@ -61,4 +61,7 @@ async fn main() -> symbiont::Result<()> { last_evolution = std::time::Instant::now(); } } + assert!(counter > 10); + + Ok(()) } diff --git a/examples/fizzbuzz/src/main.rs b/examples/fizzbuzz/src/main.rs index c5cb650..a812e4c 100644 --- a/examples/fizzbuzz/src/main.rs +++ b/examples/fizzbuzz/src/main.rs @@ -93,7 +93,7 @@ fn run_tests() -> (usize, usize, String) { async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); - let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; + let runtime = Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Debug).await?; let fn_sigs = runtime.fn_sigs(); info!("fn_sigs: {fn_sigs:?}"); diff --git a/examples/quantize/src/main.rs b/examples/quantize/src/main.rs index 5d6c403..5e4ea93 100644 --- a/examples/quantize/src/main.rs +++ b/examples/quantize/src/main.rs @@ -384,7 +384,8 @@ fn plot_frontier_progression( async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); - let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Release).await?; + let runtime = + Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Release).await?; let fn_sigs = runtime.fn_sigs(); info!("fn_sigs: {fn_sigs:?}"); diff --git a/examples/rastrigin/src/main.rs b/examples/rastrigin/src/main.rs index 1e699eb..ef047f2 100644 --- a/examples/rastrigin/src/main.rs +++ b/examples/rastrigin/src/main.rs @@ -154,7 +154,7 @@ fn sample_table(samples: &[Sample]) -> String { async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); - let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; + let runtime = Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Debug).await?; let fn_sigs = runtime.fn_sigs(); info!("fn_sigs: {fn_sigs:?}"); diff --git a/examples/sort/src/main.rs b/examples/sort/src/main.rs index 293cb0a..505a00e 100644 --- a/examples/sort/src/main.rs +++ b/examples/sort/src/main.rs @@ -229,7 +229,8 @@ fn total_time(results: &[BenchResult]) -> Duration { async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); - let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Release).await?; + let runtime = + Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Release).await?; let fn_sigs = runtime.fn_sigs(); info!("fn_sigs: {fn_sigs:?}"); diff --git a/examples/struct-support/src/main.rs b/examples/struct-support/src/main.rs index bcb00a3..2876041 100644 --- a/examples/struct-support/src/main.rs +++ b/examples/struct-support/src/main.rs @@ -7,25 +7,27 @@ use symbiont::Runtime; use tracing::info; /// A 2D game state, with just the x and y coordinates. -#[derive(Debug, Clone)] +/// +/// Annotated with `#[symbiont::shared]` so the macro records the type's +/// source code into a hidden `__SYMBIONT_SHARED_GameState` constant that +/// `evolvable!` can pull into the dylib via `shared GameState;`. +#[symbiont::shared] +#[derive(Default, Debug, Clone, PartialEq, Eq)] #[allow(dead_code, reason = "Just debug impl is used.")] struct GameState { - /// The x coodinate. Range of 0..100 + /// The x coordinate. Range of 0..100 x: usize, /// The y coordinate. Range of 0..250 y: usize, } symbiont::evolvable! { + // Bring the externally-defined `GameState` into the dylib's source so the + // evolved function below can reference it. + shared GameState; + /// Implement some different logic in here, while respecting the bounds laid out in the docs. - fn step(state: &mut GameState) { - if state.x < 100 { - state.x += 1; - } - if state.y < 250 { - state.x += 1; - } - } + fn step(state: &mut GameState); } #[tokio::main] @@ -33,24 +35,32 @@ async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); info!("SYMBIONT_DECLS: {SYMBIONT_DECLS:#?}"); - let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; + let runtime = Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Debug).await?; + let fn_prelude = runtime.fn_prelude(); let fn_source = runtime.fn_full_sources(); - info!("fn_source: {fn_source:?}"); + info!("fn_prelude: {fn_prelude:#?}, fn_source: {fn_source:#?}"); let agent = symbiont::inference::init_agent()?; let base_prompt = format!( - "Give an implementation for this function: ```{}```, \ - Give Rust Code Only.", - fn_source[0] + "Give an implementation for this evolvable function:\n +``` +{:#?} +{:#?}\n +```.\n", + fn_source[0], fn_prelude[0], ); + runtime + .evolve(&agent, &base_prompt) + .await + .expect("Can successfully evolve"); let mut last_evolution = std::time::Instant::now(); - let evolution_interval = Duration::from_secs(5); + let evolution_interval = Duration::from_secs(2); - let mut state = GameState { x: 0, y: 0 }; + let mut state = GameState::default(); - loop { + for _ in 0..10 { step(&mut state); println!("state: {state:?}"); std::thread::sleep(Duration::from_secs(1)); @@ -66,4 +76,7 @@ async fn main() -> symbiont::Result<()> { last_evolution = std::time::Instant::now(); } } + assert_ne!(state, GameState::default(), "Game state must have evolved."); + + Ok(()) } diff --git a/examples/tictactoe/src/main.rs b/examples/tictactoe/src/main.rs index 076550d..db4c0c7 100644 --- a/examples/tictactoe/src/main.rs +++ b/examples/tictactoe/src/main.rs @@ -336,7 +336,7 @@ fn overall_score(results: &[MatchResult]) -> f64 { async fn main() -> symbiont::Result<()> { symbiont::init_tracing(); - let runtime = Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug).await?; + let runtime = Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Debug).await?; let fn_sigs = runtime.fn_sigs(); info!("fn_sigs: {fn_sigs:?}"); diff --git a/symbiont-macros/Cargo.toml b/symbiont-macros/Cargo.toml index 52f1f3b..6d6d372 100644 --- a/symbiont-macros/Cargo.toml +++ b/symbiont-macros/Cargo.toml @@ -7,7 +7,7 @@ keywords = ["agent", "dylib", "dynamic", "harness", "llm"] license = "MPL-2.0" name = "symbiont-macros" repository = "https://github.com/MathisWellmann/symbiont" -version = "0.7.0" +version = "0.8.0" [lints] workspace = true diff --git a/symbiont-macros/src/evolvable.rs b/symbiont-macros/src/evolvable.rs index fffc6ae..7ddb880 100644 --- a/symbiont-macros/src/evolvable.rs +++ b/symbiont-macros/src/evolvable.rs @@ -1,5 +1,6 @@ use syn::{ ForeignItemFn, + Item, ItemFn, Signature, Visibility, @@ -7,8 +8,11 @@ use syn::{ Parse, ParseStream, }, + punctuated::Punctuated, }; +syn::custom_keyword!(shared); + /// A single function declaration inside `evolvable! { ... }`. /// /// Supports two forms: @@ -42,25 +46,73 @@ impl EvolvableFn { } } -/// The contents of an `evolvable! { ... }` block: zero or more function declarations. +/// The contents of an `evolvable! { ... }` block. +/// +/// Holds zero or more function declarations plus a "prelude" of supporting +/// items. The prelude has two complementary forms: +/// +/// - **Inline items**: structs, enums, type aliases, `use` statements, etc. +/// declared directly inside the macro body. Re-emitted in both the host +/// crate and the dylib. +/// - **Shared references**: `shared Foo, Bar;` lines that pull in items +/// annotated with `#[symbiont::shared]` from outside the macro. Each +/// reference resolves to a `__SYMBIONT_SHARED_` const containing +/// the source of the referenced item. #[expect(clippy::field_scoped_visibility_modifiers, reason = "Good here")] pub(crate) struct EvolvableBlock { pub(crate) functions: Vec, + /// Non-fn items declared inline inside the macro. + pub(crate) prelude_items: Vec, + /// Idents referenced via `shared , ; ` lines. Each maps + /// to a `__SYMBIONT_SHARED_` constant produced by the + /// `#[symbiont::shared]` attribute macro. + pub(crate) shared_refs: Vec, } impl Parse for EvolvableBlock { fn parse(input: ParseStream) -> syn::Result { let mut functions = Vec::new(); + let mut prelude_items = Vec::new(); + let mut shared_refs = Vec::new(); + while !input.is_empty() { - // Try parsing as a full function (with body) first + // `shared Foo, Bar;` — references to items annotated with + // `#[symbiont::shared]` outside the macro body. + if input.peek(shared) { + input.parse::()?; + let idents: Punctuated = + Punctuated::parse_separated_nonempty(input)?; + input.parse::()?; + shared_refs.extend(idents); + continue; + } + + // Try parsing as a full function (with body) first. let fork = input.fork(); if fork.parse::().is_ok() { functions.push(EvolvableFn::WithBody(input.parse::()?)); - } else { - // Fall back to bodyless (foreign-style) declaration - functions.push(EvolvableFn::WithoutBody(input.parse::()?)); + continue; + } + + // Try parsing as a bodyless function declaration. + let fork = input.fork(); + if fork.parse::().is_ok() { + let parsed: ForeignItemFn = input.parse()?; + functions.push(EvolvableFn::WithoutBody(parsed)); + continue; + } + + // Fall back to any other top-level item (struct/enum/type/use/...). + let item: Item = input.parse()?; + match item { + Item::Fn(f) => functions.push(EvolvableFn::WithBody(f)), + other => prelude_items.push(other), } } - Ok(EvolvableBlock { functions }) + Ok(EvolvableBlock { + functions, + prelude_items, + shared_refs, + }) } } diff --git a/symbiont-macros/src/lib.rs b/symbiont-macros/src/lib.rs index aff3601..474fcfd 100644 --- a/symbiont-macros/src/lib.rs +++ b/symbiont-macros/src/lib.rs @@ -178,7 +178,45 @@ pub fn evolvable(input: TokenStream) -> TokenStream { }); } + // Render the prelude into the per-call constant slice. The prelude has + // two sources: + // + // 1. Inline items declared inside `evolvable! { ... }` — re-emitted + // verbatim in the host crate and converted to a string literal + // that the runtime will splice into the dylib's `lib.rs`. + // 2. `shared Foo, Bar;` references — each resolves to a + // `__SYMBIONT_SHARED_` const produced by the + // `#[symbiont::shared]` attribute macro applied to types defined + // outside the macro. + let prelude_items = &block.prelude_items; + let inline_prelude_source = if prelude_items.is_empty() { + String::new() + } else { + let file = syn::File { + shebang: None, + attrs: Vec::new(), + items: prelude_items.clone(), + }; + prettyplease::unparse(&file) + }; + + let mut prelude_parts: Vec = Vec::new(); + if !inline_prelude_source.is_empty() { + prelude_parts.push(quote! { #inline_prelude_source }); + } + for ident in &block.shared_refs { + let const_ident = syn::Ident::new(&format!("__SYMBIONT_SHARED_{ident}"), ident.span()); + prelude_parts.push(quote! { #const_ident }); + } + let expanded = quote! { + #(#prelude_items)* + + #[doc(hidden)] + const SYMBIONT_PRELUDE: &[&::core::primitive::str] = &[ + #(#prelude_parts),* + ]; + const SYMBIONT_DECLS: &[::symbiont::EvolvableDecl] = &[ #(#decl_entries),* ]; @@ -188,3 +226,68 @@ pub fn evolvable(input: TokenStream) -> TokenStream { expanded.into() } + +/// Attribute macro that marks a type (struct, enum, or type alias) as +/// shared between the host crate and any `evolvable!`-generated dylib. +/// +/// Expands the annotated item unchanged, and additionally emits a +/// `pub const __SYMBIONT_SHARED_: &str = ""` constant +/// holding the item's source code. The `evolvable!` macro picks this up +/// via `shared ;` declarations. +/// +/// # Example +/// +/// ```rust,ignore +/// #[symbiont::shared] +/// #[derive(Debug, Clone)] +/// struct GameState { x: usize, y: usize } +/// +/// symbiont::evolvable! { +/// shared GameState; +/// fn step(state: &mut GameState) { state.x += 1; } +/// } +/// ``` +#[proc_macro_attribute] +pub fn shared(_attr: TokenStream, item: TokenStream) -> TokenStream { + let item_ts: proc_macro2::TokenStream = item.clone().into(); + let parsed = match syn::parse::(item) { + Ok(p) => p, + Err(e) => return e.to_compile_error().into(), + }; + + use syn::Item::*; + let ident = match &parsed { + Struct(s) => &s.ident, + Enum(e) => &e.ident, + Type(t) => &t.ident, + Union(u) => &u.ident, + _ => { + return syn::Error::new_spanned( + &parsed, + "#[symbiont::shared] only supports `struct`, `enum`, `union`, and `type` items", + ) + .to_compile_error() + .into(); + } + }; + + // Render the annotated item back to source so the runtime can splice + // the exact same definition into the dylib's `lib.rs`. + let file = syn::File { + shebang: None, + attrs: Vec::new(), + items: vec![parsed.clone()], + }; + let source = prettyplease::unparse(&file); + + let const_ident = syn::Ident::new(&format!("__SYMBIONT_SHARED_{ident}"), ident.span()); + + quote! { + #item_ts + + #[doc(hidden)] + #[allow(non_upper_case_globals)] + pub const #const_ident: &::core::primitive::str = #source; + } + .into() +} diff --git a/symbiont/Cargo.toml b/symbiont/Cargo.toml index b385f63..b24b25e 100644 --- a/symbiont/Cargo.toml +++ b/symbiont/Cargo.toml @@ -9,13 +9,13 @@ license = "MPL-2.0" name = "symbiont" readme = "README.md" repository = "https://github.com/MathisWellmann/symbiont" -version = "0.7.0" +version = "0.8.0" [lints] workspace = true [dependencies] -symbiont-macros = { version = "0.7.0", path = "../symbiont-macros" } +symbiont-macros = { version = "0.8.0", path = "../symbiont-macros" } rig-core.workspace = true tokio.workspace = true diff --git a/symbiont/benches/dispatch_overhead.rs b/symbiont/benches/dispatch_overhead.rs index d9986ad..c93ddb5 100644 --- a/symbiont/benches/dispatch_overhead.rs +++ b/symbiont/benches/dispatch_overhead.rs @@ -32,7 +32,7 @@ fn bench_dispatch_overhead(c: &mut Criterion) { // Initialize the symbiont runtime (compiles the temp dylib). let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); rt.block_on(async { - symbiont::Runtime::init(SYMBIONT_DECLS, symbiont::Profile::Debug) + symbiont::Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, symbiont::Profile::Debug) .await .expect("runtime init") }); diff --git a/symbiont/src/lib.rs b/symbiont/src/lib.rs index c06ddfe..ae3b76a 100644 --- a/symbiont/src/lib.rs +++ b/symbiont/src/lib.rs @@ -29,7 +29,10 @@ pub use error::{ }; pub use init_tracing::init_tracing; pub use runtime::Runtime; -pub use symbiont_macros::evolvable; +pub use symbiont_macros::{ + evolvable, + shared, +}; /// Internal module for macro-generated dispatch code. /// diff --git a/symbiont/src/runtime.rs b/symbiont/src/runtime.rs index e9c62de..bcfd111 100644 --- a/symbiont/src/runtime.rs +++ b/symbiont/src/runtime.rs @@ -131,6 +131,13 @@ pub struct Runtime { decls: &'static [EvolvableDecl], /// Compilation profile (`debug` or `release`). profile: Profile, + /// Supporting items (structs, enums, type aliases, `use` statements, ...) + /// either declared inline inside `evolvable! { ... }` or pulled in via + /// `shared ;` references to `#[symbiont::shared]`-annotated types. + /// Each entry is a Rust source snippet that is prepended to the dylib's + /// `lib.rs` on every (re)compilation so evolved code can reference + /// user-defined types. + prelude: &'static [&'static str], /// The currently active AST of the agent code, in String form, to make it `Send` current_clean_ast: Mutex, } @@ -179,11 +186,17 @@ impl Runtime { /// optimizer can make orders-of-magnitude difference for compute-heavy code. /// [`Profile::Debug`] compiles faster and is fine for correctness-only workloads. /// + /// # Arguments: + /// - `decls` should be the generated `SYMBIONT_DECLS` constant from the `evolvable` macro. + /// - `prelude` should be the generated `SYMBIONT_PRELUDE` constant from the `evolvable` macro too. + /// - `profile` defines the optimization level to apply to the dylib. + /// /// # Panics /// /// Panics if called more than once. pub async fn init( decls: &'static [EvolvableDecl], + prelude: &'static [&'static str], profile: Profile, ) -> Result<&'static Runtime> { if decls.is_empty() { @@ -206,7 +219,7 @@ impl Runtime { std::fs::write(crate_dir.join("Cargo.toml"), cargo_toml)?; // Write src/lib.rs from all default_source entries - let lib_rs = generate_lib_rs(decls); + let lib_rs = generate_lib_rs(decls, prelude); let mut ast = syn::parse_str(&lib_rs)?; // Compile @@ -231,6 +244,7 @@ impl Runtime { library: Mutex::new(Some(lib)), decls, profile, + prelude, current_clean_ast: Mutex::new(lib_rs), }; @@ -274,6 +288,24 @@ impl Runtime { // Validate signatures match declarations validate_generated_ast(&mut ast, &self.fn_sigs)?; + // Re-inject the prelude (user-defined structs/enums/etc.) so the + // dylib still sees the same type definitions used in the host crate. + // The LLM is asked to emit only the function bodies, so we control + // the prelude here rather than relying on the model to repeat it. + if !self.prelude.is_empty() { + let mut combined: Vec = Vec::new(); + for part in self.prelude { + if part.is_empty() { + continue; + } + let prelude_file: syn::File = syn::parse_str(part) + .expect("prelude was successfully parsed at init; should still be valid"); + combined.extend(prelude_file.items); + } + combined.append(&mut ast.items); + ast.items = combined; + } + // Recompile let t0 = Instant::now(); let clean_ast_str = unparse(&ast); @@ -462,15 +494,23 @@ impl Runtime { &self.fn_sigs } + /// Get the prelude items of the evolvable functions if any. + /// This includes structs, enums and type declarations which are relevant in the function signature. + pub fn fn_prelude(&self) -> Vec> { + Vec::from_iter(self.prelude.iter().map(|v| FullSource(v))) + } + /// Get the full function signatures, including doc comments and default function body. /// /// Returns each source wrapped in [`FullSource`], which preserves real line /// breaks when pretty-printed (`{:#?}`) so logs stay readable. + /// + /// The other relevant types that a function may require can be found in `fn_preludes`. pub fn fn_full_sources(&self) -> Vec> { Vec::from_iter(self.decls.iter().map(|d| FullSource(d.full_source))) } - /// Get the current, ,clean LLM-generated code (without panic-catching wrappers or preamble). + /// Get the current, clean LLM-generated code (without panic-catching wrappers or preamble). /// Suitable for feeding back into the LLM prompt or displaying to the user. pub fn current_code(&self) -> String { self.current_clean_ast diff --git a/symbiont/src/utils.rs b/symbiont/src/utils.rs index d51d874..5c5d910 100644 --- a/symbiont/src/utils.rs +++ b/symbiont/src/utils.rs @@ -51,8 +51,18 @@ panic = "unwind" .to_string() } -pub(crate) fn generate_lib_rs(decls: &[EvolvableDecl]) -> String { +pub(crate) fn generate_lib_rs(decls: &[EvolvableDecl], prelude: &[&str]) -> String { let mut src = String::with_capacity(1_000); + for part in prelude { + if part.is_empty() { + continue; + } + src.push_str(part); + if !part.ends_with('\n') { + src.push('\n'); + } + src.push('\n'); + } for d in decls { src.push_str(d.full_source); src.push_str("\n\n"); diff --git a/symbiont/tests/runtime.rs b/symbiont/tests/runtime.rs index 03c6f0a..2dc7978 100644 --- a/symbiont/tests/runtime.rs +++ b/symbiont/tests/runtime.rs @@ -19,7 +19,7 @@ async fn runtime() { *counter += 1; } }; - let rt = Runtime::init(SYMBIONT_DECLS, Profile::Debug) + let rt = Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, Profile::Debug) .await .expect("Can init"); assert_eq!( From f15d231fea2df4267abd78ac6ed3e0e9d3454999 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Tue, 12 May 2026 19:12:40 +0200 Subject: [PATCH 3/8] expand integration test. --- symbiont/src/decl.rs | 2 +- symbiont/tests/runtime.rs | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/symbiont/src/decl.rs b/symbiont/src/decl.rs index 5e13016..a0799df 100644 --- a/symbiont/src/decl.rs +++ b/symbiont/src/decl.rs @@ -38,7 +38,7 @@ impl fmt::Debug for EvolvableDecl { /// /// Behaves like a `&str` for all practical purposes: dereferences to `str`, /// implements `AsRef` and `Display` (which writes the source verbatim). -#[derive(Clone, Copy)] +#[derive(Clone, Copy, PartialEq, Eq)] pub struct FullSource<'a>(pub &'a str); impl FullSource<'_> { diff --git a/symbiont/tests/runtime.rs b/symbiont/tests/runtime.rs index 2dc7978..62f061e 100644 --- a/symbiont/tests/runtime.rs +++ b/symbiont/tests/runtime.rs @@ -6,6 +6,7 @@ use rig::completion::Prompt; use symbiont::{ + FullSource, Profile, Runtime, }; @@ -22,10 +23,22 @@ async fn runtime() { let rt = Runtime::init(SYMBIONT_DECLS, SYMBIONT_PRELUDE, Profile::Debug) .await .expect("Can init"); + assert_eq!(&rt.fn_sigs(), &["fn step(counter: &mut usize)".to_string()]); + assert_eq!( + &rt.fn_full_sources(), + &[FullSource( + "/// Should increment the counter by a value in the range 5..20\n#[unsafe(no_mangle)]\npub fn step(counter: &mut usize) {\n *counter += 1;\n}\n" + )] + ); assert_eq!( &rt.current_code(), "/// Should increment the counter by a value in the range 5..20\n#[unsafe(no_mangle)]\npub fn step(counter: &mut usize) {\n *counter += 1;\n}\n\n\n" ); + assert_eq!( + rt.fn_prelude(), + Vec::new(), + "No prelude items in this function." + ); let mut counter = 0; step(&mut counter); assert_eq!(counter, 1); @@ -33,11 +46,23 @@ async fn runtime() { let agent = MockAgent; let prompt = format!("Implement this function in rust: ```{}```", rt.fn_sigs()[0]); rt.evolve(&agent, &prompt).await.expect("Can evolve"); + assert_eq!(&rt.fn_sigs(), &["fn step(counter: &mut usize)".to_string()]); + assert_eq!( + &rt.fn_full_sources(), + &[FullSource( + "/// Should increment the counter by a value in the range 5..20\n#[unsafe(no_mangle)]\npub fn step(counter: &mut usize) {\n *counter += 1;\n}\n" + )] + ); assert_eq!( &rt.current_code(), "#[unsafe(no_mangle)]\npub fn step(counter: &mut usize) {\n *counter += 5;\n}\n", "Code has evolved" ); + assert_eq!( + rt.fn_prelude(), + Vec::new(), + "No prelude items in this function." + ); step(&mut counter); assert_eq!(counter, 6); } From a8158b571c66335f42825cc9154c52be78492b4c Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Tue, 12 May 2026 21:16:28 +0200 Subject: [PATCH 4/8] symbiont: more tests, remove unused impls. --- symbiont/src/compiler.rs | 11 +++++++++++ symbiont/src/decl.rs | 31 +++++++++---------------------- symbiont/src/runtime.rs | 2 +- symbiont/tests/runtime.rs | 8 ++++---- 4 files changed, 25 insertions(+), 27 deletions(-) diff --git a/symbiont/src/compiler.rs b/symbiont/src/compiler.rs index dddc074..a2f4ec3 100644 --- a/symbiont/src/compiler.rs +++ b/symbiont/src/compiler.rs @@ -96,3 +96,14 @@ pub(crate) async fn compile_dylib( }) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn profile_display() { + assert_eq!(&Profile::Release.to_string(), "release"); + assert_eq!(&Profile::Debug.to_string(), "debug"); + } +} diff --git a/symbiont/src/decl.rs b/symbiont/src/decl.rs index a0799df..e880c50 100644 --- a/symbiont/src/decl.rs +++ b/symbiont/src/decl.rs @@ -1,7 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 use std::{ fmt, - ops::Deref, sync::atomic::AtomicPtr, }; @@ -41,11 +40,9 @@ impl fmt::Debug for EvolvableDecl { #[derive(Clone, Copy, PartialEq, Eq)] pub struct FullSource<'a>(pub &'a str); -impl FullSource<'_> { - /// Borrow the underlying source string. - #[inline] - pub fn as_str(&self) -> &str { - self.0 +impl AsRef for FullSource<'static> { + fn as_ref(&self) -> &str { + &self.0 } } @@ -74,22 +71,6 @@ impl fmt::Display for FullSource<'_> { } } -impl Deref for FullSource<'_> { - type Target = str; - - #[inline] - fn deref(&self) -> &str { - self.0 - } -} - -impl AsRef for FullSource<'_> { - #[inline] - fn as_ref(&self) -> &str { - self.0 - } -} - impl<'a> From<&'a str> for FullSource<'a> { #[inline] fn from(s: &'a str) -> Self { @@ -143,4 +124,10 @@ mod tests { assert!(pretty.contains("\n todo!()\n")); assert!(!pretty.contains(r"\n")); } + + #[test] + fn full_source_as_str() { + let source = FullSource("Hello"); + assert_eq!(&source.to_string(), "Hello"); + } } diff --git a/symbiont/src/runtime.rs b/symbiont/src/runtime.rs index bcfd111..65b0de6 100644 --- a/symbiont/src/runtime.rs +++ b/symbiont/src/runtime.rs @@ -522,7 +522,7 @@ impl Runtime { /// Return the function signature and body for a single function base on its `fn_name` pub fn current_function(&self, fn_name: &str) -> Option { let code = self.current_code(); - let file: syn::File = syn::parse_str(&code).ok()?; + let file: syn::File = syn::parse_str(&code).ok()?; // Its always valid code. file.items.into_iter().find_map(|item| match item { syn::Item::Fn(f) if f.sig.ident == fn_name => Some(f), _ => None, diff --git a/symbiont/tests/runtime.rs b/symbiont/tests/runtime.rs index 62f061e..ed9df72 100644 --- a/symbiont/tests/runtime.rs +++ b/symbiont/tests/runtime.rs @@ -30,15 +30,15 @@ async fn runtime() { "/// Should increment the counter by a value in the range 5..20\n#[unsafe(no_mangle)]\npub fn step(counter: &mut usize) {\n *counter += 1;\n}\n" )] ); - assert_eq!( - &rt.current_code(), - "/// Should increment the counter by a value in the range 5..20\n#[unsafe(no_mangle)]\npub fn step(counter: &mut usize) {\n *counter += 1;\n}\n\n\n" - ); assert_eq!( rt.fn_prelude(), Vec::new(), "No prelude items in this function." ); + assert_eq!( + &rt.current_code(), + "/// Should increment the counter by a value in the range 5..20\n#[unsafe(no_mangle)]\npub fn step(counter: &mut usize) {\n *counter += 1;\n}\n\n\n" + ); let mut counter = 0; step(&mut counter); assert_eq!(counter, 1); From 97a4717a5b2a47e11bcbb09245f2d88015aa15c0 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Tue, 12 May 2026 22:10:25 +0200 Subject: [PATCH 5/8] CI changes --- .github/workflows/ci.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e75b0c0..e04eb11 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,34 +76,26 @@ jobs: # Start a local llama-cpp server via devenv, then run the counter # example for 30s. This exercises the full pipeline: dylib creation, # compilation, loading, dispatch, LLM evolution, and hot-swap. - # Exit code 124 = timeout killed the process (success). run: | nix develop --command bash -c ' devenv up -d devenv processes wait llama-server --timeout 120 - timeout 30s cargo run -p counter-example || [ $? -eq 124 ] + cargo run -p counter-example devenv processes down ' - name: fizzbuzz-example-smoke-test env: RUSTFLAGS: "-D warnings" - # Run the fizzbuzz example which evolves a function to pass a test - # suite. Timeout after 60s — the example exits on its own once all - # tests pass or after max_rounds. run: | nix develop --command bash -c ' devenv up -d devenv processes wait llama-server --timeout 120 - timeout 60s cargo run -p fizzbuzz-example || [ $? -eq 124 ] + cargo run -p fizzbuzz-example devenv processes down ' - name: rastrigin-example-smoke-test env: RUSTFLAGS: "-D warnings" - # Run the rastrigin example which evolves a function to match - # sample data via symbolic regression. Timeout after 120s — the - # example exits on its own once the formula is found or after - # max_rounds. run: | nix develop --command bash -c ' devenv up -d From a8d488e38a8cc29a792aad27977016c09b270e0a Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 13 May 2026 10:20:53 +0100 Subject: [PATCH 6/8] remove .env file. --- .env | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 .env diff --git a/.env b/.env deleted file mode 100644 index 909d742..0000000 --- a/.env +++ /dev/null @@ -1,3 +0,0 @@ -BASE_URL="http://127.0.0.1:8001/v1" -API_KEY="" -MODEL="unsloth/Qwen3.6-27B-GGUF:BF16" From 37431c5f276347eceab1327ce02840596e9add99 Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 13 May 2026 10:28:29 +0100 Subject: [PATCH 7/8] update `struct-support` example, run it in CI. --- .github/workflows/ci.yml | 10 ++++++++++ examples/struct-support/src/main.rs | 26 +++++--------------------- 2 files changed, 15 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e04eb11..1d65bbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,3 +116,13 @@ jobs: timeout 180s cargo run -p sort-example || [ $? -eq 124 ] devenv processes down ' + - name: struct-support-example-smoke-test + env: + RUSTFLAGS: "-D warnings" + run: | + nix develop --command bash -c ' + devenv up -d + devenv processes wait llama-server --timeout 120 + cargo run -p struct-support-example + devenv processes down + ' diff --git a/examples/struct-support/src/main.rs b/examples/struct-support/src/main.rs index 2876041..992a651 100644 --- a/examples/struct-support/src/main.rs +++ b/examples/struct-support/src/main.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MPL-2.0 //! The example shows support for functions taking in custom structs from the surrounding scope. -use std::time::Duration; - use symbiont::Runtime; use tracing::info; @@ -54,28 +52,14 @@ async fn main() -> symbiont::Result<()> { .evolve(&agent, &base_prompt) .await .expect("Can successfully evolve"); - - let mut last_evolution = std::time::Instant::now(); - let evolution_interval = Duration::from_secs(2); + info!( + "Successfully evolved the function, which is now hot-reloaded in-place. Next call to `step` will run the newly compiled Agent code." + ); let mut state = GameState::default(); - for _ in 0..10 { - step(&mut state); - println!("state: {state:?}"); - std::thread::sleep(Duration::from_secs(1)); - - if last_evolution.elapsed() >= evolution_interval { - runtime - .evolve(&agent, &base_prompt) - .await - .expect("Can successfully evolve"); - info!( - "Successfully evolved the function, which is now hot-reloaded in-place. Next call to `step` will run the newly compiled Agent code." - ); - last_evolution = std::time::Instant::now(); - } - } + step(&mut state); + println!("state: {state:?}"); assert_ne!(state, GameState::default(), "Game state must have evolved."); Ok(()) From 1fb56f6fb7d307acbe63c593a10d61c65639502c Mon Sep 17 00:00:00 2001 From: MathisWellmann Date: Wed, 13 May 2026 10:38:02 +0100 Subject: [PATCH 8/8] resolve cargo clippy warnings. --- symbiont-macros/src/lib.rs | 1 + symbiont/src/decl.rs | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/symbiont-macros/src/lib.rs b/symbiont-macros/src/lib.rs index 474fcfd..a163c3c 100644 --- a/symbiont-macros/src/lib.rs +++ b/symbiont-macros/src/lib.rs @@ -88,6 +88,7 @@ fn normalize_tokens(mut s: String) -> String { /// - A `SYMBIONT_DECLS` constant with metadata for each function /// - Wrapper functions that dispatch calls through the loaded dylib #[proc_macro] +#[expect(clippy::too_many_lines, reason = "one macro")] pub fn evolvable(input: TokenStream) -> TokenStream { let block = syn::parse_macro_input!(input as EvolvableBlock); diff --git a/symbiont/src/decl.rs b/symbiont/src/decl.rs index e880c50..745fdc6 100644 --- a/symbiont/src/decl.rs +++ b/symbiont/src/decl.rs @@ -41,8 +41,9 @@ impl fmt::Debug for EvolvableDecl { pub struct FullSource<'a>(pub &'a str); impl AsRef for FullSource<'static> { + #[inline(always)] fn as_ref(&self) -> &str { - &self.0 + self.0 } }