diff --git a/README.md b/README.md index 208c574..d26e848 100644 --- a/README.md +++ b/README.md @@ -106,11 +106,14 @@ The [fractal-studio-example](examples/fractal-studio/README.md) is an interactiv Type a prompt — *"an animated Julia set, c orbiting the main cardioid, with a glowing sunset palette"* — and the agent implements `fn shade(x: f64, y: f64, t: f64) -> u32`; the live animation morphs in place, no restart:
- +
-- `shade` is called once per pixel (~0.5M calls/frame at 960×540), parallelized over all cores with rayon — an interpreted agent-code loop would be orders of magnitude too slow to animate. +- `shade` is called once per pixel (~0.5M calls/frame at 960×540, and the canvas re-renders at the window's size and aspect ratio), parallelized over all cores with rayon — an interpreted agent-code loop would be orders of magnitude too slow to animate. - The user is the evaluator: the runtime keeps the chat history, so follow-up prompts refine the current shader. +- The canvas never freezes while the agent works: a single-lane `evolve_batch` compiles and registers the + candidate without touching the dispatch pointers, so rendering continues on the current revision until + `activate_revision` commits the new one in a few atomic stores at a frame boundary. - Agent code panics are caught inside the dylib (rendered as black pixels) and fed back into the next evolution prompt. ```bash diff --git a/TODO.md b/TODO.md index fe35203..52e91a1 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,7 @@ -- Show multi-threading support with example. - Show multi function evolution with example. - Show example of using external dependency in generated dylibs, if configured. -- Support disallowing methods, crates or unsafe, enforced by the harness. - Proper eval pipeline to compare model performance across tasks. My own benchmark suite so to say, aka `symbiont-eval` -- Compare examples with SOTA equivalent search strategies, see if it beats any already. - Run Harness for my symbolic regression evaluation comparison, to see if it beats SOTA for ~150 optimization targets. -- Bidirectionality, like evolving a fractal rendering function using `evolvable` and a UI in the main harness binary shows the results. -- Capture the number of evolution failures by category, e.g how many compile errors, how many parse errors, how many HTTP errors, etc. - Capture the inference cost in the responses, if available. - Prefix-cache visibility is provider-dependent: `LLM_TOKENS{kind="cached_input"}` comes from `usage.prompt_tokens_details.cached_tokens`, which vLLM never populates (verified: identical @@ -18,8 +13,6 @@ means paying dependency compilation per lane. Only worth it once `symbiont_build_slot_wait_seconds` says so — see [CAVEATS.md](CAVEATS.md). - Track the context length of the prompt (system + user) and make it available to query. -- Cap the runtime of agent code to a user-specified maximum to prevent infine loops in agent code. - This is not really possible, unless the function signature has cooperative cancellation code passed in like an `stop: AtomicBool` and the agent must ensure to check it in each loop round. - Provide a way to call `info`, `debug` and `trace` like logging functions in the code and have them feed into the context in a smart way. Maybe its possible to re-use `tracing` here, depending on if its safe to do across dylib boundaries. It would need to be its own buffer though. @@ -27,3 +20,7 @@ Maybe rig has some native DB support? - Support passing in images if the LLM supports multi-modality. Giving Agents image context might help improve the reasoning in certain problem cases. +- example of evolving a CUDA kernel. +- example for an interactive background daemon that creates dynamic wallpapers based on user prompts / revision. + * Similar to fractal studio + * Could be CPU native or CUDA accelerated diff --git a/examples/fractal-studio/README.md b/examples/fractal-studio/README.md index aeed27a..a5d042c 100644 --- a/examples/fractal-studio/README.md +++ b/examples/fractal-studio/README.md @@ -16,7 +16,7 @@ hot-swaps the dylib. The live animation morphs in place, no restart. ## Why this showcases symbiont - **Bare-metal performance where it matters**: `shade` is called once per pixel - (~0.5M calls/frame at 960×540), parallelized over all cores with rayon, with + (~0.5M calls/frame at 960×540, more on a larger window), parallelized over all cores with rayon, with fractal workloads running hundreds of iterations per pixel. The ~1.6 ns dispatch overhead makes the hot-swap abstraction effectively free — an interpreted agent-code loop would be orders of magnitude too slow to animate. @@ -32,17 +32,39 @@ hot-swaps the dylib. The live animation morphs in place, no restart. ## Architecture Three threads, coordinated around the feedback-loop contract -(*no evolvable call may be in flight while the dylib is swapped*): +(*no evolvable call may be in flight while the dispatch pointers are swapped*): - **egui UI** (main thread): canvas, prompt box, telemetry (ms/frame, Mpix/s), and a syntax-highlighted view of the live agent code. - **render thread**: tight frame loop calling `shade` for every pixel via - rayon. Parks at a frame boundary when an evolution is requested. -- **evolution worker**: drains the render gate, runs `Runtime::evolve` on a - tokio runtime, publishes the new code, resumes rendering. + rayon, at whatever size the UI last reported (capped at ~1080p worth of + pixels, then upscaled). Parks at a frame boundary only for the revision + swap. -The animation freezes (showing the last frame) while the agent generates and -compiles, then resumes with the new shader — that pause *is* the contract. +The canvas is re-rendered at the window's aspect ratio instead of being fitted +into it, so resizing never letterboxes — which means `aspect` is not a +constant, and the evolution prompt tells the agent as much. +- **evolution worker**: runs a single-lane `Runtime::evolve_batch` on a tokio + runtime, then drains the render gate and calls + `Runtime::activate_revision`. + +### The animation keeps running while the agent works + +`evolve_batch` compiles and *registers* the candidate without touching the +dispatch pointers, which is why it is exempt from the feedback-loop contract: +a retained-but-inactive revision is invisible to running calls. The render +thread therefore keeps calling the **current** revision's function pointer for +the whole generate → validate → compile round — seconds of LLM inference plus a +`cargo build --release` — and the canvas never freezes. + +Only the commit is gated. `activate_revision` republishes function pointers +that were resolved when the dylib was loaded, so it is a handful of atomic +stores: the render thread parks at a frame boundary, the swap happens, and +rendering resumes. The side panel reports that park time in microseconds next +to the multi-second evolution time. + +The one visible effect during an evolution is a frame-rate dip: the nested +`cargo build` competes for the same cores rayon renders on. ## Running diff --git a/examples/fractal-studio/src/main.rs b/examples/fractal-studio/src/main.rs index 9d9374f..600fbe6 100644 --- a/examples/fractal-studio/src/main.rs +++ b/examples/fractal-studio/src/main.rs @@ -14,15 +14,23 @@ //! live telemetry, and the current agent-generated code. //! - render thread: tight loop calling the evolvable `shade` for every pixel //! via `rayon`, pushing finished frames to the UI. -//! - evolution worker: receives user prompts, parks the render thread at a -//! frame boundary (the feedback-loop contract: no evolvable calls may be in -//! flight during `Runtime::evolve`), evolves, then resumes rendering. +//! - evolution worker: receives user prompts and runs `Runtime::evolve_batch` +//! with a single lane, which generates, validates, compiles and *registers* +//! the candidate without touching the dispatch pointers. Rendering keeps +//! running off the active revision the whole time; only the final +//! `activate_revision` needs the render thread parked, and that costs a +//! handful of atomic stores at a frame boundary instead of the multi-second +//! generate-and-compile round. use std::{ sync::{ Arc, Condvar, Mutex, + atomic::{ + AtomicU64, + Ordering, + }, mpsc::{ Receiver, Sender, @@ -49,7 +57,10 @@ symbiont::evolvable! { /// # Coordinates /// - `x`, `y`: canvas coordinates with `(0.0, 0.0)` at the center. /// `y` spans `[-1.0, 1.0]` (positive is up); `x` spans - /// `[-aspect, +aspect]` where `aspect = width / height` (~1.78). + /// `[-aspect, +aspect]` where `aspect = width / height`. The canvas is + /// re-rendered at the window's aspect ratio whenever it is resized, so + /// `aspect` is not a constant: keep the composition centered instead of + /// assuming a particular width. /// - `t`: seconds since program start — use it for smooth animation /// (palette cycling, zooming, morphing parameters, ...). /// @@ -62,48 +73,197 @@ symbiont::evolvable! { /// and parallelized across all cores by the host, so per-call cost must /// stay bounded (cap iteration counts). fn shade(x: f64, y: f64, t: f64) -> u32 { - // Default implementation: a gently pulsing grayscale Mandelbrot set, - // so the canvas shows something before the first evolution. - const MAX_ITER: u32 = 256; - let cx = x * 0.95 - 0.6; - let cy = y * 0.95; - let (mut zx, mut zy) = (0.0_f64, 0.0_f64); + // Default implementation: a Julia set that morphs and cycles color, + // so the canvas is alive before the first evolution. + // + // `c` crawls along the boundary of the Mandelbrot set's main cardioid + // (pulled 0.5% inwards, where the sets stay connected but stringy), so + // the shape continuously grows and folds new filaments. The exterior + // is colored by the *smooth* (fractional) escape count and faded to + // black away from the set; the interior — flat black in the textbook + // rendering — is colored by an orbit trap on the closest the orbit + // ever came to the origin, which lights it up as glowing nebulae with + // contour rings. Both go through the same cosine palette, whose phase + // drifts with `t`. + const MAX_ITER: u32 = 192; + // A large bailout is what makes the smooth iteration count smooth: + // the fractional part converges as the escape radius grows. + const ESCAPE: f64 = 256.0; + + /// Cosine palette: cheap, periodic, saturated everywhere. + fn palette(s: f64) -> (f64, f64, f64) { + use std::f64::consts::TAU; + ( + 0.55 + 0.45 * (TAU * (s + 0.00)).cos(), + 0.45 + 0.40 * (TAU * (s + 0.28)).cos(), + 0.55 + 0.45 * (TAU * (s + 0.62)).cos(), + ) + } + + /// Pack floats into `0x00_RR_GG_BB`, gamma corrected. + fn pack(r: f64, g: f64, b: f64) -> u32 { + let q = |v: f64| (v.clamp(0.0, 1.0).sqrt() * 255.0) as u32; + (q(r) << 16) | (q(g) << 8) | q(b) + } + + // Slow breathing zoom keeps the composition from feeling static. + let zoom = 1.35 + 0.10 * (t * 0.19).sin(); + let (mut zx, mut zy) = (x * zoom, y * zoom); + + // Cardioid boundary: c(th) = e^(i*th)/2 - e^(2i*th)/4. The offset + // start angle opens on a filigreed set rather than a round blob. + let th = 0.8 + t * 0.09; + let (sin_th, cos_th) = th.sin_cos(); + let (sin_2th, cos_2th) = (2.0 * th).sin_cos(); + let cx = 0.995 * (0.5 * cos_th - 0.25 * cos_2th); + let cy = 0.995 * (0.5 * sin_th - 0.25 * sin_2th); + + let mut m = zx * zx + zy * zy; + let mut trap = m; let mut i = 0_u32; - while zx * zx + zy * zy <= 4.0 && i < MAX_ITER { + while m <= ESCAPE && i < MAX_ITER { let next_zx = zx * zx - zy * zy + cx; zy = 2.0 * zx * zy + cy; zx = next_zx; + m = zx * zx + zy * zy; + if m < trap { + trap = m; + } i += 1; } + if i == MAX_ITER { - return 0x000000; + // Interior: glow by how tightly the orbit hugged the origin. + let d = trap.sqrt(); + let glow = (-2.0 * d).exp(); + let band = 0.82 + 0.18 * (d * 26.0 - t).sin(); + let (r, g, b) = palette(0.30 + 0.55 * d - 0.04 * t); + let k = (0.10 + 0.90 * glow) * band; + return pack(r * k, g * k, b * k); } - let pulse = 0.75 + 0.25 * (t * 0.8).sin(); - let v = ((f64::from(i) / f64::from(MAX_ITER)).sqrt() * 255.0 * pulse) as u32; - (v << 16) | (v << 8) | v + + // Exterior: continuous escape count, so no iteration banding. + let smooth = f64::from(i) + 1.0 - (0.5 * m.ln()).ln() / std::f64::consts::LN_2; + let (r, g, b) = palette(0.11 * smooth.max(0.0).sqrt() + 0.05 * t); + // Fade the fast-escaping far field to black to frame the set. + let v = (smooth / 22.0).min(1.0).powf(1.6); + pack(r * v, g * v, b * v) } } -/// Fixed render resolution; the canvas is scaled to fit the window. -const WIDTH: usize = 960; -/// Fixed render resolution; the canvas is scaled to fit the window. -const HEIGHT: usize = 540; -/// Aspect ratio used to scale the `x` coordinate passed to `shade`. -const ASPECT: f64 = WIDTH as f64 / HEIGHT as f64; +/// Canvas size before the UI has reported how much space it has. +const INITIAL_SIZE: (usize, usize) = (960, 540); +/// Smallest canvas edge in physical pixels, so a collapsed panel cannot +/// produce a zero-sized (or degenerate one-pixel) render target. +const MIN_EDGE: usize = 64; +/// Upper bound on the number of pixels rendered per frame (~1080p). +/// +/// Beyond this the per-frame shader cost grows faster than the visible gain; +/// the canvas keeps the window's aspect ratio and is upscaled by the GPU, so +/// it still fills the panel edge to edge — a maximized 4K window just renders +/// slightly softer instead of dropping to a few frames per second. +const MAX_PIXELS: usize = 1920 * 1080; +/// Canvas edges are rounded down to a multiple of this, so dragging the +/// window does not reallocate and re-render on every sub-pixel change. +const SIZE_QUANTUM: usize = 8; /// Frame pacing target (~60 fps). Rendering faster than this just burns CPU. const TARGET_FRAME_TIME: Duration = Duration::from_millis(16); -/// Render one full frame into an RGB byte buffer (3 bytes per pixel) by -/// calling the hot-swappable `shade` function for every pixel, parallelized -/// over rows with rayon. -fn render_frame(t: f64, rgb: &mut [u8]) { - rgb.par_chunks_mut(WIDTH * 3) +/// The canvas size the UI wants, in physical pixels, packed as +/// `(width << 32) | height`. +/// +/// Written by the UI thread whenever the panel is laid out and read by the +/// render thread at every frame boundary. A relaxed atomic rather than a +/// mutex: the two sides never need to agree on *when* a resize takes effect, +/// only that the render thread eventually picks the latest value up. +#[derive(Debug)] +struct CanvasSize(AtomicU64); + +impl CanvasSize { + /// Start at [`INITIAL_SIZE`] until the UI reports its available space. + fn new() -> Self { + let (width, height) = INITIAL_SIZE; + Self(AtomicU64::new(((width as u64) << 32) | height as u64)) + } + + /// Record the size the UI has room for, in physical pixels. + /// + /// The request is quantized, clamped to [`MIN_EDGE`], and scaled down to + /// [`MAX_PIXELS`] while preserving the requested aspect ratio — matching + /// that aspect ratio is what keeps the canvas free of letterbox bars. + fn request(&self, width: f32, height: f32) { + let (mut width, mut height) = (f64::from(width).max(1.0), f64::from(height).max(1.0)); + let pixels = width * height; + let budget = MAX_PIXELS as f64; + if pixels > budget { + let scale = (budget / pixels).sqrt(); + width *= scale; + height *= scale; + } + let quantize = |v: f64| { + let v = v as usize / SIZE_QUANTUM * SIZE_QUANTUM; + v.max(MIN_EDGE) + }; + let packed = ((quantize(width) as u64) << 32) | quantize(height) as u64; + self.0.store(packed, Ordering::Relaxed); + } + + /// The current canvas size in physical pixels. + fn get(&self) -> (usize, usize) { + let packed = self.0.load(Ordering::Relaxed); + ((packed >> 32) as usize, (packed & 0xFFFF_FFFF) as usize) + } +} + +#[cfg(test)] +mod canvas_size_tests { + use super::*; + + #[test] + fn packs_and_unpacks_both_edges() { + let size = CanvasSize::new(); + assert_eq!(size.get(), INITIAL_SIZE); + size.request(1280.0, 720.0); + assert_eq!(size.get(), (1280, 720)); + } + + #[test] + fn quantizes_and_clamps_to_min_edge() { + let size = CanvasSize::new(); + size.request(1283.0, 727.0); + assert_eq!(size.get(), (1280, 720)); + size.request(0.0, -5.0); + assert_eq!(size.get(), (MIN_EDGE, MIN_EDGE)); + } + + #[test] + fn scales_oversized_requests_down_keeping_the_aspect_ratio() { + let size = CanvasSize::new(); + size.request(3840.0, 2160.0); + let (width, height) = size.get(); + assert!(width * height <= MAX_PIXELS, "{width}x{height}"); + // The aspect ratio is what keeps the canvas free of letterbox bars. + let aspect = width as f64 / height as f64; + assert!((aspect - 3840.0 / 2160.0).abs() < 0.01, "aspect {aspect}"); + } +} + +/// Render one full frame of `width` x `height` into an RGB byte buffer +/// (3 bytes per pixel) by calling the hot-swappable `shade` function for +/// every pixel, parallelized over rows with rayon. +/// +/// The aspect ratio is derived from the frame size rather than fixed, so the +/// coordinate system `shade` sees always matches the shape of the window and +/// the result can be blitted edge to edge. +fn render_frame(t: f64, rgb: &mut [u8], width: usize, height: usize) { + let aspect = width as f64 / height as f64; + rgb.par_chunks_mut(width * 3) .enumerate() .for_each(|(py, row)| { // `y` points up: top row maps to +1, bottom row to -1. - let y = 1.0 - 2.0 * (py as f64 / (HEIGHT - 1) as f64); + let y = 1.0 - 2.0 * (py as f64 / (height - 1) as f64); for (px, pixel) in row.chunks_exact_mut(3).enumerate() { - let x = (2.0 * (px as f64 / (WIDTH - 1) as f64) - 1.0) * ASPECT; + let x = (2.0 * (px as f64 / (width - 1) as f64) - 1.0) * aspect; // Bare-metal call into the hot-loaded native dylib. let c = shade(x, y, t); pixel[0] = ((c >> 16) & 0xFF) as u8; @@ -126,8 +286,12 @@ enum GateState { } /// Synchronizes the render thread with the evolution worker so that no -/// evolvable function call is in flight while [`Runtime::evolve`] hot-swaps -/// the dylib (the feedback-loop contract). +/// evolvable function call is in flight while +/// [`Runtime::activate_revision`] republishes the dispatch pointers (the +/// feedback-loop contract). +/// +/// Only the pointer swap is gated — generation and compilation happen while +/// the render thread runs freely against the previous revision. #[derive(Debug)] struct Gate { /// Current gate state. @@ -158,8 +322,10 @@ impl Gate { } } - /// Called by the evolution worker. Blocks until the render thread has - /// parked at a frame boundary, guaranteeing no in-flight `shade` calls. + /// Called by the evolution worker right before the swap. Blocks until the + /// render thread has parked at a frame boundary, guaranteeing no in-flight + /// `shade` calls. Held for the duration of one pointer store, so the + /// animation misses at most a frame. fn drain(&self) { let mut state = self.state.lock().expect("gate mutex is not poisoned"); if *state == GateState::Run { @@ -170,7 +336,7 @@ impl Gate { } } - /// Called by the evolution worker after the hot-swap to resume rendering. + /// Called by the evolution worker after the swap to resume rendering. fn resume(&self) { *self.state.lock().expect("gate mutex is not poisoned") = GateState::Run; self.cvar.notify_all(); @@ -181,6 +347,7 @@ impl Gate { #[derive(Debug, Clone)] struct SharedUi { /// True while the evolution worker is generating / compiling / swapping. + /// Rendering continues throughout; only the swap parks the render thread. evolving: bool, /// The current agent-generated code running in the dylib. code: String, @@ -188,6 +355,8 @@ struct SharedUi { panic_msg: Option