The Lagra engine is twelve typed layers. Read this like an AI architecture spec: each layer has a single input type, a single output type, and a single job. The architecture is fixed — what's pluggable is the leaves (physics-node variants, integrator expressions, render targets). This page walks every layer with the math, the code, and a visual.
Twelve detail layers grouped into four stages. Click a stage to jump to its first layer below.
No special-cases between layers. Same architecture for a two-bead oscillator, a 1000-block AVBD stack, and a 60-particle DNA helix.
.lagra scene fileSourceSource.lagra is a declarative DSL. A scene declares what exists (entities, bonds, gravity, etc.), how it should be stepped, and what invariants should hold — it does not contain integrator code, no loop, no force calculation.
Every later layer in the pipeline is mechanically derived from this file. The scene on the right is the entire input for the two-bead harmonic oscillator we'll trace through all twelve layers.
Source: configs/scenes/solver_walkthrough_oscillator.lagra.
scene solver_walkthrough_oscillator { uses newtonian.classical_mechanics description "Two-bead harmonic oscillator." entity { type: ball_small, position: [-1.35, 0, 0], mass: 1.0, size: 0.35 } entity { type: ball_small, position: [ 1.35, 0, 0], mass: 1.0, size: 0.35 } bond harmonic [0, 1] { k: 2.0, r0: 2.0, damping: 0.04 } @expect energy_conservation // CI gate solver { integrator: "velocity_verlet", dt: 0.002, steps_per_frame: 12 } }
Source (UTF-8)&strVec<Token>tokensThe lexer (crates/lagra-lang/src/lexer.rs) is a hand-written scanner. It groups characters into tokens: keywords (scene, entity, bond, solver, @expect), identifiers, literals (numbers, strings, vectors), and punctuation ({, }, [, ], :, ,).
Each token carries a Span — the start and end byte offsets in the source — so any later error can be reported with a precise underline against the original file.
Lagra's lexer is intentionally simple. No operator overloading, no string interpolation, no significant whitespace. A token is a tag plus a span.
// "scene oscillator { uses newtonian.classical_mechanics …" [ Token { kind: Keyword(scene), span: 0..5 }, Token { kind: Ident(oscillator), span: 6..16 }, Token { kind: Punct('{'), span: 17..18 }, Token { kind: Keyword(uses), span: 23..27 }, Token { kind: Ident(newtonian), span: 28..37 }, Token { kind: Punct('.'), span: 37..38 }, Token { kind: Ident(classical_mechanics) }, Token { kind: Keyword(entity), span: … }, Token { kind: Punct('{') }, Token { kind: Ident(type) }, Token { kind: Punct(':') }, Token { kind: Ident(ball_small) }, Token { kind: Punct(',') }, Token { kind: Ident(position) }, Token { kind: Punct(':') }, Token { kind: Punct('[') }, Token { kind: Literal(-1.35) }, // … ]
Vec<Token>tokensSceneDecl (root AST node)ASTThe parser is recursive-descent over a small grammar. Each grammar rule maps to a typed AST node: SceneDecl, EntityDecl, BondDecl, SolverDecl, ExpectDecl, plus literal nodes for vectors and scalars.
The AST is the first structured representation: from here on, no later layer needs to look at characters or spans (except for error reporting). The shape of the scene is a tree.
A parse failure points to the token where the parser ran out of choices, with the surrounding source line as context — the lexer's span data flows through.
SceneDecl { name: "solver_walkthrough_oscillator" } ├─ UsesDecl "newtonian.classical_mechanics" ├─ EntityDecl #0 │ ├─ type: ball_small │ ├─ position: Vec3(-1.35, 0, 0) │ ├─ mass: 1.0 │ └─ size: 0.35 ├─ EntityDecl #1 │ ├─ type: ball_small │ ├─ position: Vec3(1.35, 0, 0) │ ├─ mass: 1.0 │ └─ size: 0.35 ├─ BondDecl { kind: harmonic, ids: [0, 1] } │ ├─ k: 2.0 │ ├─ r0: 2.0 │ └─ damping: 0.04 ├─ ExpectDecl energy_conservation └─ SolverDecl ├─ integrator: "velocity_verlet" ├─ dt: 0.002 └─ steps_per_frame: 12
SceneDecl (untyped)ASTTypedScene + SymbolTabletyped ASTThe typer walks the AST once and produces a typed version, resolving identifiers and rejecting nonsense early. position: [-1.35, 0, 0] becomes a Vec3<f64>; bond harmonic [0, 1] resolves the 0 and 1 against the entity table and checks that those indices exist.
This is where domain errors fire: an unknown entity index in a bond, an integrator name that's not registered, a tolerance that's negative. The compiler refuses to run a scene that's structurally invalid — the kernel never sees a malformed scene.
The symbol table is small: entity ids, node types, integrator name, declared expects. Later layers consult it to dispatch on these tags without re-parsing.
struct TypedScene { particles: Vec<Particle>, // 2 entries nodes: Vec<LagrangianNodeDecl>, // 1 entry stepper: StepperDecl, verifiers: Vec<VerifierDecl>, symtab: SymbolTable, } // resolved + checked for the oscillator: particles = [ Particle { id: 0, pos: Vec3(-1.35, 0, 0), m: 1.0 }, Particle { id: 1, pos: Vec3(1.35, 0, 0), m: 1.0 }, ] nodes = [ LagrangianNodeDecl::HarmonicBond { i: 0, j: 1, k: 2.0, r0: 2.0, damping: 0.04, } ] stepper = StepperDecl::FormulaStepper { integrator: "velocity_verlet", dt: 0.002, steps_per_frame: 12, } verifiers = [ VerifierDecl::EnergyConservation { tol: 1e-5 } ]
TypedScenetyped ASTLagrangianGraph + FormulaStepper + Verifier set + Schedule + RenderPlanruntimeThe compile step lowers each part of the typed AST into a real runtime data structure. Each AST node has a one-to-one mapping into a typed runtime object — the engine never special-cases physics domains, so the lowering is mechanical.
EntityDecl → a Particle in the LagrangianGraph. BondDecl → a LagrangianNode variant. SolverDecl → a FormulaStepper with the integrator parsed as a .lagra expression. ExpectDecl → a closure derived from the Lagrangian via Noether's theorem (see the verifier section).
The planner then derives a Schedule (parallelism, memory layout, broad-phase if needed) and a RenderPlan (camera, frame cadence, output target) — both are tiny for this oscillator and large for the 1000-block AVBD scene, but the kernel's shape doesn't change.
LagrangianGraph { particles: [0: Vec3(-1.35,0,0) m=1, 1: Vec3(1.35,0,0) m=1], nodes: [HarmonicBond {i:0, j:1, k:2.0, r0:2.0}], } FormulaStepper { rhs: "velocity_verlet", // parsed .lagra expression dt: 2e-3, n_substeps: 12, } VerifierSet { [ EnergyConservation { H: |state| T(state) + V(state), // auto-derived tol: 1e-5, } ] } Schedule { backend: Cpu, threads: 1, cell_list: None } RenderPlan { camera: orbit(y=2), cadence: EveryFrame, target: CpuRaster { w: 1280, h: 720 } }
LagrangianGraph + initial conditionscompiledWorld[0] — positions, velocities, masses, forcesStateWorld is the state vector — three flat arrays (positions, velocities, masses) of length |V|, plus a scratch forces buffer of the same length. No nested types, no per-particle heap allocations.
Bootstrap is mechanical: copy particles[i].pos into World.pos[i], zero the velocities (or seed them per the scene), zero the forces. From this point on, every step of the engine is just a transformation World[N] → World[N+1].
The flat layout is what lets the kernel be cache-friendly and lets the GPU path (when enabled) upload the whole state in a single buffer. Same memory shape for two beads or two million.
struct World { pos: Vec<Vec3>, // length |V| vel: Vec<Vec3>, mass: Vec<f64>, forces: Vec<Vec3>, // scratch, zeroed each step t: f64, step: u64, } // World[0]: pos = [Vec3(-1.35, 0, 0), Vec3(1.35, 0, 0)] vel = [Vec3( 0, 0, 0), Vec3( 0, 0, 0)] mass = [1.0, 1.0] forces = [Vec3( 0, 0, 0), Vec3( 0, 0, 0)] t = 0.0 step = 0
&World[N] + &LagrangianGraphread&mut World.forces — Fi for each particlewriteThe action kernel is the heart of the engine. Mathematically, the equation of motion derived from the Lagrangian via δS = 0 is
The kernel is the right-hand side of that equation. It walks every active node in the LagrangianGraph and lets each node contribute its local gradient to the connected vertices' force buffers. One for-loop, no branching on physics domain — the same kernel runs a HarmonicBond, a Coulomb, or a LennardJones evaluator.
For the oscillator: one HarmonicBond node contributes ∂Vb/∂qi = ±k(|r| − r₀) r̂ to particles 0 and 1.
pub fn evaluate(graph: &LagrangianGraph, world: &mut World) { world.forces.fill(Vec3::zero()); // Σ starts at 0 for node in graph.active_nodes() { // Σ_{n ∈ E} for i in node.nbrs() { // only touched vertices let g_i = node.grad_potential(i, &world.pos); world.forces[i] -= g_i; // −∂V_n/∂q_i } } } // HarmonicBond's grad_potential: impl LagrangianNode for HarmonicBond { fn grad_potential(&self, i: usize, pos: &[Vec3]) -> Vec3 { let r_vec = pos[self.j] - pos[self.i]; let r = r_vec.norm(); let dir = r_vec / r; let sign = if i == self.i { -1.0 } else { 1.0 }; sign * self.k * (r - self.r0) * dir } }
&World[N] + World.forcesreadWorld[N+1] — new pos & velwriteThe default integrator for classical-mechanics scenes is velocity-Verlet — second-order accurate, time-reversible, and symplectic: it doesn't conserve energy exactly, but it conserves a slightly modified Hamiltonian, so the energy stays bounded over arbitrarily long runs. That's the property the verifier (layer 09) relies on.
The four-step update on the right is the symplectic recipe: half-kick velocity using current force, drift positions, recompute force at new positions, half-kick velocity again. The middle graph.evaluate() call is layer 07 — the solver doesn't know which physics it's integrating.
The integrator itself is a .lagra expression, not hardcoded. Swap to symplectic-Euler, AVBD, or discrete-action by editing the solver { ... } block — same kernel underneath.
fn step(world: &mut World, graph: &LagrangianGraph, dt: f64) { // 1. half-kick velocity with current force for i in 0..world.n() { world.vel[i] += 0.5 * dt * world.forces[i] / world.mass[i]; } // 2. drift positions a full dt for i in 0..world.n() { world.pos[i] += dt * world.vel[i]; } // 3. recompute forces at the NEW positions (calls layer 07) graph.evaluate(world); // 4. half-kick velocity with the new force for i in 0..world.n() { world.vel[i] += 0.5 * dt * world.forces[i] / world.mass[i]; } world.t += dt; world.step += 1; }
&World[N+1] + &VerifierSetreadVerdict — PASS or FAIL with drift valuesverdictEach @expect annotation in the scene becomes a verifier function. The function isn't hand-written — it's derived from the Lagrangian via Noether's theorem: each continuous symmetry of L gives a conserved quantity, and the verifier measures that quantity on the engine's output trajectory.
For energy_conservation: the Lagrangian is time-translation invariant (∂L/∂t = 0), so by Noether the Hamiltonian H = T + V is conserved. The verifier computes H from every World[N], tracks the drift against H(t=0), and fails the frame if the drift exceeds the declared tolerance.
The same verifier runs in CI, in the IDE overlay, and in the wasm HUD on the demos — one function, three callers, no separate test suite to maintain.
// derived from: @expect energy_conservation fn verify(scene: &Scene, traj: &[State]) -> Verdict { // L(q, q̇) = T(q̇) − V(q) // ∂L/∂t = 0 ⟹ H = T + V conserved (Noether) let H = |s: &State| -> f64 { let T = scene.particles.iter() .map(|p| 0.5*p.mass*s.vel[p.id].norm_sq()).sum(); let V = scene.graph.nodes.iter() .map(|n| n.potential(&s.pos)).sum(); T + V }; let H0 = H(&traj[0]); let drift = traj.iter() .map(|s| (H(s) - H0).abs() / H0) .fold(0.0, f64::max); if drift < scene.tolerance { Pass { drift } } else { Fail { drift } } } // for our oscillator: drift ≈ 3.2e-7 < tol 1e-5 → PASS
The verifier function above doesn't fall from the sky. The compiler derives it from the Lagrangian in seven mechanical steps. The user writes one annotation; everything else is Noether's theorem.
@expect energy_conservation.lagra. Everything that follows is mechanical — no test code to maintain.@expect energy_conservation ↓ fn verify(scene, traj) { … H = T + V … }@expect), a proof term (the trajectory), an independent checker (this verifier). Same code in CI, IDE overlay, and the wasm HUD.&World[N+1] + &RenderPlanreadFrameDecision { emit, camera_pose }decisionRendering is a separate concern. The kernel-solver-verifier loop runs without a window; frames are pulled on a schedule the RenderPlan decides.
The cadence options aren't just "every frame" vs "every Nth frame". A scene can gate frame emission on physics: emit when kinetic energy crosses a threshold, when a verifier signals a transition, when a user-supplied predicate fires. Headless agent harnesses set emit = false and pay nothing for rendering they don't consume.
The camera pose can also be procedural — orbit-around-COM, follow-particle-i, fixed-from-scene-bounds. The engine doesn't need a windowing system to drive it.
enum Cadence { EveryFrame, EveryNth(u32), GatedOn(fn(&World) -> bool), Off, // headless agent harness } enum CameraPose { Fixed(Mat4), OrbitCom { axis: Vec3, period: f64 }, FollowParticle{ id: usize, distance: f64 }, FromBounds { padding: f64 }, } fn plan_frame(world: &World, plan: &RenderPlan) -> FrameDecision { let emit = match plan.cadence { Cadence::EveryFrame => true, Cadence::EveryNth(n) => world.step % n as u64 == 0, Cadence::GatedOn(pred) => pred(world), Cadence::Off => false, }; FrameDecision { emit, camera_pose: plan.camera.evaluate(world) } }
&World + camera_posereadRGBA[W × H × 4]pixelsThe CPU rasterizer is the canonical render path. It runs everywhere the engine runs — native, wasm, headless — and produces the exact same bytes. The wasm demos on the landing page hand its output to putImageData; the native build hands it to a wgpu surface; the headless harness encodes it to PNG.
The pipeline per frame is the textbook one: project each primitive (particle, bond, mesh) into clip space with the camera matrix, clip, divide by w, splat or scanline-rasterize into the framebuffer. Particles use a circular splat with size proportional to a per-particle attribute (mass, charge, kinetic energy).
On native, the optional gpu feature adds a wgpu compute-shader path for dense systems — same primitives, different backend.
fn rasterize(world: &World, cam: Mat4, fb: &mut Framebuffer) { fb.clear(Color::BG); // particles → circular splats for i in 0..world.n() { let clip = cam * world.pos[i].xyzw(); let ndc = clip.xyz() / clip.w; let px = ndc_to_pixel(ndc, fb.w, fb.h); let radius = splat_radius(world.mass[i], ndc.z); splat(fb, px, radius, Color::PARTICLE); } // edges → line segments for e in world.bonds() { draw_line(fb, project(e.a, cam), project(e.b, cam), Color::BOND); } fb.as_rgba() // &[u8] of length W*H*4 }
RGBA[W × H × 4]pixelsSame buffer, three targets. The engine never knows which target it's feeding — the dispatch is one match arm at the very edge.
In the browser (the demos on the landing page), the wasm linear memory holds the pixel buffer; JS calls putImageData on a 2D canvas with a zero-copy view. On native, the buffer is presented through wgpu's swapchain. Headless: encode to PNG or stream raw RGBA to stdout — the regression harness uses this path to diff frames byte-for-byte against the reference.
That's the whole pipeline. Twelve typed layers, deterministic at every boundary, the same architecture for every scene the engine runs.
match target { Target::Canvas(canvas) => { // wasm: zero-copy view into linear memory let view = Uint8ClampedArray::view(&pixels); let img = ImageData::new(&view, w, h)?; canvas.put_image_data(&img, 0.0, 0.0)?; } Target::NativeSurface(surface) => { let frame = surface.get_current_texture()?; upload(&frame, &pixels); frame.present(); } Target::Png(path) => { png::encode(&path, &pixels, w, h)?; // regression harness } Target::Stream(writer) => { writer.write_all(&pixels)?; // raw RGBA to stdout } }
Twelve typed layers. No physics-domain branching in the hot path. Add a new physics term by registering a LagrangianNode variant. Add a new integrator by writing a .lagra expression. Add a new verifier by writing one @expect. The architecture is fixed; the leaves are extensible.
The position paper formalizes the substrate behind lagra-core: typed action graphs, a single action kernel, and an independent verifier layer — written in the style of Zheng et al. 2026 (arXiv:2605.06651). Everything walked through on this page in code is stated in the paper as theorem-and-proof.