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, /// Error message of the last failed evolution, if any. evolve_error: Option, + /// Size of the most recently rendered frame, in physical pixels. + canvas: (usize, usize), /// Most recent frame time in milliseconds. frame_ms: f64, /// Most recent throughput in megapixels per second. @@ -196,6 +365,9 @@ struct SharedUi { evolutions: usize, /// Wall-clock duration of the last successful evolution in seconds. last_evolve_secs: Option, + /// How long the render thread was parked for the last revision swap, in + /// microseconds — the only part of an evolution that stalls the animation. + last_swap_us: Option, } /// Slot holding the most recently rendered frame for the UI to pick up. @@ -207,6 +379,7 @@ fn spawn_render_thread( gate: Arc, shared: Arc>, frame_slot: FrameSlot, + canvas_size: Arc, runtime: &'static Runtime, ctx: egui::Context, ) { @@ -214,12 +387,18 @@ fn spawn_render_thread( .name("symbiont-render".to_string()) .spawn(move || { let start = Instant::now(); - let mut rgb = vec![0_u8; WIDTH * HEIGHT * 3]; + let mut rgb = Vec::new(); loop { gate.frame_boundary(); + // Adopt the size the UI last asked for. Resizing between + // frames (never during one) keeps `shade`'s coordinate system + // consistent across a single image. + let (width, height) = canvas_size.get(); + rgb.resize(width * height * 3, 0); + let frame_start = Instant::now(); - render_frame(start.elapsed().as_secs_f64(), &mut rgb); + render_frame(start.elapsed().as_secs_f64(), &mut rgb, width, height); let frame_time = frame_start.elapsed(); // Panics inside the agent code are caught in the dylib and @@ -233,11 +412,12 @@ fn spawn_render_thread( } *frame_slot.lock().expect("frame slot mutex is not poisoned") = - Some(egui::ColorImage::from_rgb([WIDTH, HEIGHT], &rgb)); + Some(egui::ColorImage::from_rgb([width, height], &rgb)); { let mut s = shared.lock().expect("shared state mutex is not poisoned"); + s.canvas = (width, height); s.frame_ms = frame_time.as_secs_f64() * 1e3; - s.mpix_per_s = (WIDTH * HEIGHT) as f64 / frame_time.as_secs_f64() / 1e6; + s.mpix_per_s = (width * height) as f64 / frame_time.as_secs_f64() / 1e6; } ctx.request_repaint(); @@ -258,10 +438,13 @@ fn spawn_render_thread( fn evolution_prompt( fn_sig: &str, user_prompt: &str, + canvas: (usize, usize), frame_ms: f64, mpix_per_s: f64, panic_msg: Option, ) -> String { + let (width, height) = canvas; + let aspect = width as f64 / height as f64; let panic_feedback = panic_msg.map_or_else(String::new, |msg| { format!( "The previous implementation panicked at runtime: \"{msg}\". Avoid that failure mode.\n" @@ -272,10 +455,13 @@ fn evolution_prompt( The user wants the canvas to show: {user_prompt}\n\ Canvas conventions: `(x, y)` is the pixel position with `(0, 0)` at \ the center; `y` spans [-1, 1] (positive is up) and `x` spans \ - [-{ASPECT:.2}, {ASPECT:.2}]. `t` is seconds since program start — use \ - it for smooth animation. Return the color packed as `0x00_RR_GG_BB`.\n\ + [-aspect, +aspect] where aspect is the window's aspect ratio, \ + currently {aspect:.2} but it changes when the user resizes the \ + window — do not hard-code it, and keep the composition centered. \ + `t` is seconds since program start — use it for smooth animation. \ + Return the color packed as `0x00_RR_GG_BB`.\n\ Telemetry of the previous implementation: {frame_ms:.1} ms/frame at \ - {WIDTH}x{HEIGHT} ({mpix_per_s:.1} Mpix/s).\n\ + {width}x{height} ({mpix_per_s:.1} Mpix/s).\n\ {panic_feedback}\ Hard constraints: keep the exact signature. The function must be pure \ (no allocation, no I/O, no statics, no unsafe). It is called once per \ @@ -285,9 +471,17 @@ fn evolution_prompt( ) } -/// Spawn the evolution worker: for each user prompt it parks the render -/// thread (feedback-loop contract), runs [`Runtime::evolve`] on the tokio -/// runtime, publishes the new agent code to the UI, and resumes rendering. +/// Spawn the evolution worker: for each user prompt it runs a single-lane +/// [`Runtime::evolve_batch`] on the tokio runtime, which registers the new +/// revision *without* activating it, then parks the render thread just long +/// enough to [`Runtime::activate_revision`]. +/// +/// This is what keeps the canvas alive during an evolution: `evolve_batch` is +/// explicitly exempt from the feedback-loop contract, because a +/// retained-but-inactive revision is invisible to running calls. So the render +/// thread keeps hammering the previous revision's function pointer for the +/// entire generate → validate → compile round, and the animation only pauses +/// for the atomic stores that commit the winner. fn spawn_evolution_worker( prompt_rx: Receiver, gate: Arc, @@ -304,33 +498,58 @@ fn spawn_evolution_worker( // agent is free to invent a fresh algorithm each evolution. let fn_sig = runtime.fn_sigs()[0].clone(); while let Ok(user_prompt) = prompt_rx.recv() { - let (frame_ms, mpix_per_s, panic_msg) = { + let (canvas, frame_ms, mpix_per_s, panic_msg) = { let mut s = shared.lock().expect("shared state mutex is not poisoned"); s.evolving = true; s.evolve_error = None; - (s.frame_ms, s.mpix_per_s, s.panic_msg.take()) + (s.canvas, s.frame_ms, s.mpix_per_s, s.panic_msg.take()) }; ctx.request_repaint(); - let prompt = - evolution_prompt(&fn_sig, &user_prompt, frame_ms, mpix_per_s, panic_msg); + let prompt = evolution_prompt( + &fn_sig, + &user_prompt, + canvas, + frame_ms, + mpix_per_s, + panic_msg, + ); - // Feedback-loop contract: park the render thread so no - // evolvable call is in flight while the dylib is swapped. - gate.drain(); + // One lane, one candidate. Unlike `evolve`, this registers the + // revision without publishing it, so the render thread needs + // no gating here and the animation keeps running. let evolve_start = Instant::now(); - let result = tokio_handle.block_on(runtime.evolve(&agent, &prompt)); + let result = tokio_handle + .block_on(runtime.evolve_batch(&agent, std::slice::from_ref(&prompt))) + .pop() + .expect("one result per prompt"); + let evolve_secs = evolve_start.elapsed().as_secs_f64(); + + // The candidate is compiled and loaded; committing to it is + // the only step bound by the feedback-loop contract. Park the + // render thread at a frame boundary, swap, resume. + let swap = result.and_then(|revision| { + let swap_start = Instant::now(); + gate.drain(); + let activated = runtime.activate_revision(revision); + gate.resume(); + activated.map(|()| (revision, swap_start.elapsed())) + }); + { let mut s = shared.lock().expect("shared state mutex is not poisoned"); - match result { - Ok(revision) => { + match swap { + Ok((revision, swap_time)) => { s.code = runtime.current_code(); s.evolutions += 1; - s.last_evolve_secs = Some(evolve_start.elapsed().as_secs_f64()); + s.last_evolve_secs = Some(evolve_secs); + s.last_swap_us = Some(swap_time.as_secs_f64() * 1e6); s.panic_msg = None; info!( - "Evolution #{} hot-swapped successfully (revision {revision}).", - s.evolutions + "Evolution #{} hot-swapped successfully (revision {revision}, \ + render thread parked for {:.0} us).", + s.evolutions, + swap_time.as_secs_f64() * 1e6 ); } Err(e) => { @@ -340,7 +559,6 @@ fn spawn_evolution_worker( } s.evolving = false; } - gate.resume(); ctx.request_repaint(); } }) @@ -353,6 +571,8 @@ struct FractalApp { shared: Arc>, /// Latest rendered frame, produced by the render thread. frame_slot: FrameSlot, + /// Canvas size requested from the render thread, updated on every layout. + canvas_size: Arc, /// GPU texture holding the current frame. texture: Option, /// Contents of the prompt input box. @@ -369,16 +589,22 @@ impl FractalApp { s.frame_ms, s.mpix_per_s )); ui.monospace(format!( - "canvas {WIDTH}x{HEIGHT} evolutions {}", - s.evolutions + "canvas {}x{} evolutions {}", + s.canvas.0, s.canvas.1, s.evolutions )); if let Some(secs) = s.last_evolve_secs { ui.monospace(format!("last evolution took {secs:.1} s")); } + if let Some(us) = s.last_swap_us { + ui.monospace(format!("of which the canvas was parked {us:.0} us")); + } if s.evolving { ui.horizontal(|ui| { ui.spinner(); - ui.label("evolving: generating → validating → compiling → hot-swapping ..."); + ui.label( + "evolving: generating → validating → compiling ... \ + (canvas keeps animating on the current revision)", + ); }); } if let Some(err) = &s.evolve_error { @@ -457,24 +683,38 @@ impl FractalApp { Self::code_section(ui, &snapshot); }); if snapshot.evolving { - // Keep the spinner animated while the render thread is parked. + // The render thread requests repaints on its own, but the cargo + // build competes for every core: keep the spinner ticking even if + // frames get sparse. ui.ctx().request_repaint_after(Duration::from_millis(100)); } } - /// The central canvas, scaled to fit while preserving aspect ratio. + /// The central canvas, filling all the space the side panel leaves. + /// + /// Rather than fitting a fixed-resolution image into the panel — which + /// letterboxes as soon as the window's aspect ratio differs from the + /// render target's — the panel size is reported back to the render thread, + /// which renders the *next* frame at exactly that aspect ratio. The image + /// is then drawn at the full available size, so there are no bars. fn canvas(&self, ui: &mut egui::Ui) { egui::CentralPanel::default() .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) .show_inside(ui, |ui| { + let avail = ui.available_size(); + let points_to_pixels = ui.ctx().pixels_per_point(); + self.canvas_size + .request(avail.x * points_to_pixels, avail.y * points_to_pixels); + let Some(texture) = &self.texture else { ui.centered_and_justified(|ui| ui.spinner()); return; }; - let avail = ui.available_size(); - let scale = (avail.x / WIDTH as f32).min(avail.y / HEIGHT as f32); - let size = egui::vec2(WIDTH as f32 * scale, HEIGHT as f32 * scale); - ui.centered_and_justified(|ui| ui.image((texture.id(), size))); + // While a resize is in flight the last frame still has the + // previous aspect ratio; stretching it for the frame or two + // until the render thread catches up reads better than bars + // appearing and disappearing during the drag. + ui.image((texture.id(), avail)); }); } } @@ -543,18 +783,22 @@ fn main() -> eframe::Result<()> { code: runtime.current_code(), panic_msg: None, evolve_error: None, + canvas: INITIAL_SIZE, frame_ms: 0.0, mpix_per_s: 0.0, evolutions: 0, last_evolve_secs: None, + last_swap_us: None, })); let frame_slot: FrameSlot = Arc::new(Mutex::new(None)); + let canvas_size = Arc::new(CanvasSize::new()); let (prompt_tx, prompt_rx) = channel(); spawn_render_thread( Arc::clone(&gate), Arc::clone(&shared), Arc::clone(&frame_slot), + Arc::clone(&canvas_size), runtime, cc.egui_ctx.clone(), ); @@ -571,6 +815,7 @@ fn main() -> eframe::Result<()> { Ok(Box::new(FractalApp { shared, frame_slot, + canvas_size, texture: None, prompt_input: String::new(), prompt_tx, diff --git a/website/static/fractal-studio-example.mp4 b/website/static/fractal-studio-example.mp4 index 9e1ca07..74d05c3 100644 Binary files a/website/static/fractal-studio-example.mp4 and b/website/static/fractal-studio-example.mp4 differ