graph · the engine, layer by layer

From .lagra source
to a pixel buffer

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.

The whole pipeline, at a glance

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.

01source

The .lagra scene file

inputUTF-8 text bytestext/plain
outputUTF-8 text bytes, typed as SourceSource

.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.

solver_walkthrough_oscillator.lagradeclarative
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 }
}
02lexer

Text into a stream of typed tokens

inputSource (UTF-8)&str
outputVec<Token>tokens

The 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.

first ~16 tokensVec<Token>
// "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)                     },
  // …
]
03parser

Tokens into a structured AST

inputVec<Token>tokens
outputSceneDecl (root AST node)AST

The 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.

parsed AST for oscillator.lagratree
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
04type check

Resolving references & checking types

inputSceneDecl (untyped)AST
outputTypedScene + SymbolTabletyped AST

The 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.

typing the oscillator sceneResult<TypedScene, TypeErr>
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 } ]
05compile

Typed AST → runtime objects

inputTypedScenetyped AST
outputLagrangianGraph + FormulaStepper + Verifier set + Schedule + RenderPlanruntime

The 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.

runtime objects producedlagra-core types
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 } }
06bootstrap

The initial state vector · World[0]

inputLagrangianGraph + initial conditionscompiled
outputWorld[0] — positions, velocities, masses, forcesState

World 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.

World[0] for the oscillatorstruct of arrays
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
07action kernel

graph.evaluate() · the gradient sum

input&World[N] + &LagrangianGraphread
output&mut World.forces — Fi for each particlewrite

The action kernel is the heart of the engine. Mathematically, the equation of motion derived from the Lagrangian via δS = 0 is

mii  =  − ∂V/∂qi  =  − Σn ∈ E ∂Vn/∂qi

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.

graph.evaluate()src/world/graph/kernel/action_graph/evaluator.rs
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
    }
}
the graph · forces emerging2 vertices, 1 edge
q₁ ∈ ℝ³ q₂ ∈ ℝ³ b · HarmonicBond · V_b = ½ k (|q₂ − q₁| − r₀)² q₁ q₂ F_i = − Σ_n ∂V_n / ∂q_i
08solver

velocity-Verlet · advancing the state

input&World[N] + World.forcesread
outputWorld[N+1] — new pos & velwrite

The 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.

FormulaStepper::stepsrc/world/graph/kernel/solver_executor.rs
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;
}
phase-space view of the symplectic step(q, p) per substep
p q phase space (q, p) — symplectic flow preserves area N N+1 N+2 N+3 ellipse closes — bounded energy over arbitrary time
09verifier

Noether-derived gates · PASS or FAIL

input&World[N+1] + &VerifierSetread
outputVerdictPASS or FAIL with drift valuesverdict

Each @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.

verify_energy_conservation.rsauto-generated
// 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

How the verifier knows what to check

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.

01declareuser · scene
@expect energy_conservation
The user writes one line in .lagra. Everything that follows is mechanical — no test code to maintain.
02compose Lagrangianlagra-lang
L(q, q̇) = T(q̇)V(q)
T = ½ Σᵢ mᵢ |q̇ᵢ|²  ·  V = Σₙ Vₙ(q)
Same Lagrangian the kernel walks (Layer 07). T from particle velocities. V summed over every node in the graph.
03find the symmetries∂L/∂t = 0
L(q, q̇, t) = L(q, q̇, t + δt)
∂L/∂t = 0 (time-translation invariant)
Inspect L for continuous symmetries. The oscillator's L doesn't depend on t — shift time by any δt and L is unchanged.
04apply Noether's theorem1918
time-translation  ⟹  H = T + V is conserved
space → momentum  ·  rotation → angular momentum  ·  gauge → charge
Every continuous symmetry of L gives a conserved quantity. Mechanical bridge from what L is to what must be preserved. No taste, no manual choice.
05emit verifier functioncompiler
@expect energy_conservation    fn verify(scene, traj) { … H = T + V … }
The compiler doesn't ask for a verifier — it writes one. The function shown above the cards is closed-form, derived from L and Noether's mapping.
06measure on the trajectorylive
drift = maxs ∈ traj |H(s) − H₀| / H₀
Each frame: compute H from positions and velocities, compare to H(t=0). The Lagrangian is the contract; the trajectory is the proof term.
07gateverdict
drift < tol  →  PASS   ·   drift ≥ tol  →  FAIL
Same structure as a Lean theorem: a proposition (@expect), a proof term (the trajectory), an independent checker (this verifier). Same code in CI, IDE overlay, and the wasm HUD.
10render plan

Deciding whether to emit a frame

input&World[N+1] + &RenderPlanread
outputFrameDecision { emit, camera_pose }decision

Rendering 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.

RenderPlan cadence optionsdecision
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) }
}
11rasterizer

World coordinates → pixel buffer

input&World + camera_poseread
outputRGBA[W × H × 4]pixels

The 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.

rasterize_framecrates/lagra-render/src/cpu.rs
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
}
world → screen, schematicprojection
world (q ∈ ℝ³) 0 1 cam · q project · clip · NDC pixels (W × H × 4) two splats, one frame
12output

Pixel buffer to display

inputRGBA[W × H × 4]pixels
outputcanvas / native surface / PNG bytestarget

Same 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.

three output paths · one buffertarget enum
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
    }
}

That's the engine

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.

→ see it run live → benchmarks & design → github

paper · pdf

Lagrangians as Action Graphs

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.

~12 pp · single-column · pdflatex · bibtex
→ read the paper (PDF) source · README