From f045e04dae6acb8384bc5d0d5bc4f821243ea35f Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 20 Oct 2026 11:40:45 -0700 Subject: [PATCH 1/4] Bench: fixture F6, real-time game scenes run frame by frame Three scenes (arena, pile, bullets) start a trusted host, install the game's contract package and build the world, then run 600 frames and tear down. Each frame submits one step intent with that frame's input, admits it, ticks once, and reads every body back as a renderer would. A pure Q16.16 physics step drives both the Echo rule and an in-memory reference; a run is valid only if the graph holds exactly the reference world and replaying retained patches reproduces the live root. The bench reports start-up, frame p50/p90/p99/max, frames over 16.7 ms, the render read, teardown and replay, and the game's own step time apart from Echo's. The rule runs the step twice per frame (footprint and executor), so Echo's share is reported as a stated model: the frame less twice the reference step time for the same frame. The step intent carries the scene's dimensions and the seed, so the rule reads nothing outside the graph and the intent; warp-core admits no global state, benches included (scripts/ban-globals.sh). The debug test f6_small_scenes_run_validly runs scaled scenes under footprint enforcement, with contacts made and broken and projectiles despawning. Written on claude/game-fixture (e5645148), never compiled there; carried onto main and compiled here. Co-Authored-By: Claude Opus 5.5 --- crates/warp-core/Cargo.toml | 5 + crates/warp-core/benches/game_scenes.rs | 117 ++ .../benches/support/fixtures/game.rs | 1374 +++++++++++++++++ .../warp-core/benches/support/fixtures/mod.rs | 2 + crates/warp-core/tests/scene_fixture_tests.rs | 33 +- 5 files changed, 1530 insertions(+), 1 deletion(-) create mode 100644 crates/warp-core/benches/game_scenes.rs create mode 100644 crates/warp-core/benches/support/fixtures/game.rs diff --git a/crates/warp-core/Cargo.toml b/crates/warp-core/Cargo.toml index 3d7aea44..c4516f96 100644 --- a/crates/warp-core/Cargo.toml +++ b/crates/warp-core/Cargo.toml @@ -90,6 +90,11 @@ name = "scene_fixtures" harness = false required-features = ["native_rule_bootstrap", "trusted_runtime"] +[[bench]] +name = "game_scenes" +harness = false +required-features = ["native_rule_bootstrap", "trusted_runtime"] + [[bench]] name = "wal_record_cost" harness = false diff --git a/crates/warp-core/benches/game_scenes.rs b/crates/warp-core/benches/game_scenes.rs new file mode 100644 index 00000000..38157038 --- /dev/null +++ b/crates/warp-core/benches/game_scenes.rs @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +#![allow( + missing_docs, + dead_code, + clippy::expect_used, + clippy::panic, + clippy::print_stdout, + clippy::cast_precision_loss +)] +//! F6 game scenes: start each scene, run its frames, tear it down. +//! +//! ```bash +//! cargo bench -p warp-core --features native_rule_bootstrap,trusted_runtime \ +//! --bench game_scenes +//! ``` +//! +//! Each scene runs once per commitment profile. A frame is one submitted and +//! admitted step intent, one tick, and a render read of every body; the +//! report gives frame percentiles, how many frames fit a 60 Hz budget, and +//! the game's own physics time for comparison. Only a valid run reports +//! figures. + +#[path = "support/fixtures/mod.rs"] +mod fixtures; + +use std::time::Duration; + +use fixtures::game::{self, Observation, Scene, FRAME_BUDGET}; +use fixtures::manifest::hex32; + +const SEED: u64 = 6; + +fn ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1e3 +} + +fn percentile(samples: &[Duration], p: f64) -> f64 { + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let index = ((sorted.len().saturating_sub(1)) as f64 * p).round() as usize; + sorted.get(index).copied().map_or(0.0, ms) +} + +fn report(scene: Scene, profile: &str, obs: &Observation) { + let class = game::validate(scene, obs); + let spec = game::workload(scene, SEED); + println!( + "F6 scene={} profile={profile} workload_id={} class={}", + scene.name, + hex32(&spec.workload_id()), + class.label() + ); + if !class.comparable() { + println!("F6 scene={} detail: {class:?}", scene.name); + return; + } + let t = &obs.timings; + let over = t + .frames + .iter() + .filter(|frame| **frame > FRAME_BUDGET) + .count(); + // Echo's share of each frame, modeled: the frame less the game's step, + // which runs twice inside it (once in the rule's footprint, once in its + // executor; the engine computes a candidate's footprint once). The step + // is pure, so each in-frame run does the work the reference timed for + // the same frame. The render read stays in, as the host's read of Echo + // state. + let in_frame_physics: Vec = t.physics.iter().map(|step| *step * 2).collect(); + let echo: Vec = t + .frames + .iter() + .zip(&in_frame_physics) + .map(|(frame, physics)| frame.saturating_sub(*physics)) + .collect(); + println!( + "F6 scene={} profile={profile} frames={} peak_bodies={} peak_contacts={} \ + startup_ms={:.2} frame_p50_ms={:.3} frame_p90_ms={:.3} frame_p99_ms={:.3} \ + frame_max_ms={:.3} over_16.7ms={over} render_p50_ms={:.3} physics_p50_ms={:.3} \ + in_frame_physics_model_p50_ms={:.3} echo_model_p50_ms={:.3} echo_model_p99_ms={:.3} echo_model_max_ms={:.3} \ + teardown_ms={:.2} replay_ms={:.1}", + scene.name, + t.frames.len(), + obs.peak_bodies, + obs.peak_contacts, + ms(t.startup), + percentile(&t.frames, 0.5), + percentile(&t.frames, 0.9), + percentile(&t.frames, 0.99), + percentile(&t.frames, 1.0), + percentile(&t.render, 0.5), + percentile(&t.physics, 0.5), + percentile(&in_frame_physics, 0.5), + percentile(&echo, 0.5), + percentile(&echo, 0.99), + percentile(&echo, 1.0), + ms(t.teardown), + ms(t.replay), + ); +} + +fn main() { + for scene in game::SCENES { + report(scene, "legacy-reachable-v1", &game::run(scene, SEED)); + #[cfg(feature = "application_root_profile")] + report( + scene, + "application-radix-v0", + &game::run_with_profile( + scene, + SEED, + warp_core::application_root::CommitmentProfile::ApplicationRadixV0, + ), + ); + } +} diff --git a/crates/warp-core/benches/support/fixtures/game.rs b/crates/warp-core/benches/support/fixtures/game.rs new file mode 100644 index 00000000..2a8e7526 --- /dev/null +++ b/crates/warp-core/benches/support/fixtures/game.rs @@ -0,0 +1,1374 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Fixture F6: a real-time game scene, run like a game. +//! +//! Each scene starts a trusted host, installs the game's contract package and +//! builds its world, then runs frames, then tears down. Every frame does what +//! a game loop does: it submits one step intent carrying that frame's input, +//! admits it, ticks, and reads every body back as a renderer would. +//! +//! **The game.** Bodies are circles with fixed-point (Q16.16) positions and +//! velocities. A step applies input, ages and despawns projectiles, spawns new +//! ones from emitters, integrates under gravity, bounces off the walls, sorts +//! bodies into a broadphase grid, and resolves overlapping pairs with a +//! position correction and an impulse, in canonical order. The same pure +//! [`step`] function drives both the Echo rule and the in-memory reference +//! the fixture validates against, so a valid run means Echo stored exactly +//! what the game computed. +//! +//! **The graph.** A root links a world node, which links one node per grid +//! cell. Each body is a node with its state as an attachment, and an edge +//! from the cell it is in; a body that changes cell gets a new edge. Each +//! touching pair has a contact edge from the lower body to the higher, added +//! when they touch and deleted when they part. A despawned body loses its +//! edges, then its node. +//! +//! **One rewrite per frame.** The step is one rule application that reads the +//! whole world and writes what changed, as a single-threaded game loop would. +//! Footprints must name every edge a rule writes, so the footprint runs the +//! same step to find them. Splitting the world into per-cell rewrites would +//! let workers share a frame, but neighbouring cells overlap, and Echo +//! rejects a conflicting rewrite outright; that variant is not built here. + +use std::collections::{BTreeMap, BTreeSet}; +use std::time::{Duration, Instant}; + +use bytes::Bytes; +use echo_registry_api::{ + ArgDef, ContractArtifactVerificationPolicy, ObjectDef, OpDef, OpKind, RegistryInfo, + RegistryProvider, +}; +use warp_core::{ + make_head_id, make_intent_kind, make_node_id, make_type_id, make_warp_id, AtomPayload, + AttachmentKey, AttachmentValue, ConflictPolicy, ContractMutationHandler, + ContractPackageIdentity, EdgeId, EdgeRecord, EngineBuilder, ExecutionGraphView, Footprint, + GraphStore, GraphView, InboxPolicy, IngressEnvelope, IngressTarget, InstalledContractPackage, + IntentOutcome, NodeId, NodeKey, NodeRecord, PatternGraph, PlaybackMode, RewriteRule, + RuleExecutor, SchedulerKind, TickDelta, TrustedRuntimeHost, TypeId, WarpOp, WorldlineId, + WorldlineRuntime, WorldlineState, WriterHead, WriterHeadKey, +}; + +use super::manifest::{Construction, EvidenceClass, WorkloadSpec}; +use super::outcome::{Accounting, Checks, ResultClass}; +use super::prng::SplitMix64; + +/// Bumped whenever this generator's logic changes. +pub const FIXTURE_VERSION: u32 = 1; + +const OP_ID: u32 = 7106; +const RULE_NAME: &str = + "cmd/contract/6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f/7106/step"; +const RULE_ID_LABEL: &str = + "rule:cmd/contract/6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f/7106/step"; +const SCHEMA_SHA256_HEX: &str = "6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f"; +const WORLDLINE: [u8; 32] = [0xF6; 32]; +const WARP: &str = "bench-fixture-f6"; + +/// One unit in Q16.16. +pub const ONE: i64 = 1 << 16; +/// A frame's budget at 60 Hz. +pub const FRAME_BUDGET: Duration = Duration::from_micros(16_667); + +/// What a body is. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Kind { + /// Moves under gravity and collides. + Dynamic = 0, + /// A dynamic body steered by input. + Player = 1, + /// Spawned by an emitter; ignores gravity and other projectiles; expires. + Projectile = 2, + /// Fixed; spawns projectiles; does not collide. + Emitter = 3, +} + +impl Kind { + fn from_u8(value: u8) -> Option { + Some(match value { + 0 => Self::Dynamic, + 1 => Self::Player, + 2 => Self::Projectile, + 3 => Self::Emitter, + _ => return None, + }) + } + + const fn collides(self) -> bool { + !matches!(self, Self::Emitter) + } +} + +/// One body. Lengths and speeds are Q16.16 world units (per frame). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Body { + /// Stable body number; it determines the body's node. + pub id: u32, + /// What the body is. + pub kind: Kind, + /// Centre. + pub pos: [i64; 2], + /// Velocity per frame. + pub vel: [i64; 2], + /// Radius. + pub radius: i64, + /// Frames left for a projectile; zero otherwise. + pub ttl: u32, +} + +const BODY_BYTES: usize = 4 + 1 + 8 * 5 + 4; + +impl Body { + fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(BODY_BYTES); + out.extend_from_slice(&self.id.to_le_bytes()); + out.push(self.kind as u8); + for value in [ + self.pos[0], + self.pos[1], + self.vel[0], + self.vel[1], + self.radius, + ] { + out.extend_from_slice(&value.to_le_bytes()); + } + out.extend_from_slice(&self.ttl.to_le_bytes()); + out + } + + fn decode(bytes: &[u8]) -> Option { + if bytes.len() != BODY_BYTES { + return None; + } + let value = |at: usize| -> Option { + Some(i64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?)) + }; + Some(Self { + id: u32::from_le_bytes(bytes[0..4].try_into().ok()?), + kind: Kind::from_u8(bytes[4])?, + pos: [value(5)?, value(13)?], + vel: [value(21)?, value(29)?], + radius: value(37)?, + ttl: u32::from_le_bytes(bytes[45..49].try_into().ok()?), + }) + } +} + +/// A scene: explicit dimensions, not a label. +#[derive(Clone, Copy, Debug)] +pub struct Scene { + /// Scene name. + pub name: &'static str, + /// Index carried in the step intent, so the rule knows the scene. + pub index: u8, + /// Dynamic bodies at start. + pub bodies: u32, + /// Body radius, whole units. + pub radius: i64, + /// World width and height, whole units. + pub world: i64, + /// Grid cell size, whole units. At least twice the largest radius plus + /// the largest per-frame speed, so contacts are found in neighbour cells. + pub cell: i64, + /// Gravity per frame, Q16.16. + pub gravity: i64, + /// Restitution, out of 256. + pub restitution: i64, + /// Largest initial speed per axis, Q16.16. + pub initial_speed: i64, + /// Whether body 0 is a player steered by input. + pub player: bool, + /// Emitters, spaced across the world. + pub emitters: u32, + /// Projectiles each emitter spawns per frame. + pub spawn_per_emitter: u32, + /// Frames a projectile lives. + pub projectile_ttl: u32, + /// Projectile speed per axis, Q16.16. + pub projectile_speed: i64, + /// Frames run. + pub frames: u64, +} + +impl Scene { + fn grid(&self) -> i64 { + self.world / self.cell + } + + fn cells(&self) -> u32 { + u32::try_from(self.grid() * self.grid()).expect("cell count fits u32") + } + + /// The scene with `frames` frames and its body and spawn counts scaled by + /// `numer / denom`, for quick tests. + pub const fn scaled(mut self, numer: u32, denom: u32, frames: u64) -> Self { + self.bodies = self.bodies * numer / denom; + self.emitters = if self.emitters == 0 { + 0 + } else { + let scaled = self.emitters * numer / denom; + if scaled == 0 { + 1 + } else { + scaled + } + }; + self.frames = frames; + self + } +} + +/// A sparse arena: bodies drift and bounce, and a player steers through them. +pub const ARENA: Scene = Scene { + name: "arena", + index: 0, + bodies: 256, + radius: 6, + world: 1024, + cell: 32, + gravity: 0, + restitution: 230, + initial_speed: 3 * ONE, + player: true, + emitters: 0, + spawn_per_emitter: 0, + projectile_ttl: 0, + projectile_speed: 0, + frames: 600, +}; + +/// A dense pile: bodies fall into a box and stay in contact. +pub const PILE: Scene = Scene { + name: "pile", + index: 1, + bodies: 2_048, + radius: 4, + world: 512, + cell: 16, + gravity: ONE / 8, + restitution: 64, + initial_speed: ONE / 2, + player: false, + emitters: 0, + spawn_per_emitter: 0, + projectile_ttl: 0, + projectile_speed: 0, + frames: 600, +}; + +/// Projectile churn: emitters fire every frame and projectiles expire, among +/// a few drifting bodies. +pub const BULLETS: Scene = Scene { + name: "bullets", + index: 2, + bodies: 128, + radius: 6, + world: 1024, + cell: 32, + gravity: 0, + restitution: 230, + initial_speed: 2 * ONE, + player: true, + emitters: 8, + spawn_per_emitter: 4, + projectile_ttl: 60, + projectile_speed: 6 * ONE, + frames: 600, +}; + +/// Every scene, in run order. +pub const SCENES: [Scene; 3] = [ARENA, PILE, BULLETS]; + +/// The canonical workload of a scene. +pub fn workload(scene: Scene, seed: u64) -> WorkloadSpec { + WorkloadSpec { + fixture: "F6", + fixture_version: FIXTURE_VERSION, + seed, + construction: Construction::AdmittedOperations, + evidence: EvidenceClass::Exploratory, + dimensions: vec![ + ("scene", u64::from(scene.index)), + ("bodies", u64::from(scene.bodies)), + ("world_units", u64::try_from(scene.world).unwrap_or(0)), + ("cell_units", u64::try_from(scene.cell).unwrap_or(0)), + ("emitters", u64::from(scene.emitters)), + ("spawn_per_emitter", u64::from(scene.spawn_per_emitter)), + ("projectile_ttl", u64::from(scene.projectile_ttl)), + ("frames", scene.frames), + ("writer_heads", 1), + ], + settings: vec![ + ("inbox", "accept-all"), + ("admission", "installed-contract"), + ("durability", "none"), + ("ticks", "one-step-intent-per-frame"), + ("physics", "q16.16-impulse-v1"), + ], + history_operations: vec![ + "frames-admitted-operations", + "replay-apply-retained-patches", + ], + } +} + +/// The game world: what the step reads and produces. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct World { + /// Frames stepped. + pub frame: u64, + /// The next body number to allocate. + pub next_id: u32, + /// Bodies by number. + pub bodies: BTreeMap, + /// Touching pairs, lower number first. + pub contacts: BTreeSet<(u32, u32)>, +} + +/// The world a scene starts with. +pub fn initial_world(scene: Scene, seed: u64) -> World { + let mut rng = SplitMix64::new(seed ^ (u64::from(scene.index) << 56)); + let mut world = World::default(); + let radius = scene.radius * ONE; + // Bodies start on a jittered lattice, so none overlap. + let spacing = scene.radius * 3; + let per_row = (scene.world / spacing).max(1); + for n in 0..scene.bodies { + let row = i64::from(n) / per_row; + let col = i64::from(n) % per_row; + let jitter = |rng: &mut SplitMix64| { + i64::try_from( + rng.below(u64::try_from(scene.radius / 2 * ONE).unwrap_or(1).max(1)) + .unwrap_or(0), + ) + .unwrap_or(0) + }; + let x = (col * spacing + spacing / 2) * ONE + jitter(&mut rng); + let y = (scene.world - 1 - (row * spacing + spacing / 2)) * ONE - jitter(&mut rng); + let speed = |rng: &mut SplitMix64| { + let span = u64::try_from(2 * scene.initial_speed + 1).unwrap_or(1); + i64::try_from(rng.below(span).unwrap_or(0)).unwrap_or(0) - scene.initial_speed + }; + let vel = [speed(&mut rng), speed(&mut rng)]; + let kind = if scene.player && n == 0 { + Kind::Player + } else { + Kind::Dynamic + }; + let id = world.next_id; + world.next_id += 1; + world.bodies.insert( + id, + Body { + id, + kind, + pos: [x, y.max(radius)], + vel, + radius, + ttl: 0, + }, + ); + } + for e in 0..scene.emitters { + let x = scene.world * ONE * i64::from(2 * e + 1) / i64::from(2 * scene.emitters); + let id = world.next_id; + world.next_id += 1; + world.bodies.insert( + id, + Body { + id, + kind: Kind::Emitter, + pos: [x, scene.world * ONE / 2], + vel: [0, 0], + radius, + ttl: 0, + }, + ); + } + world +} + +/// The input of frame `frame`: an acceleration for the player. +pub fn input(scene: Scene, seed: u64, frame: u64) -> [i64; 2] { + if !scene.player { + return [0, 0]; + } + let mut rng = SplitMix64::new(seed ^ 0xA5A5_0000 ^ frame); + let axis = |rng: &mut SplitMix64| i64::try_from(rng.below(5).unwrap_or(2)).unwrap_or(2) - 2; + [axis(&mut rng) * ONE / 4, axis(&mut rng) * ONE / 4] +} + +fn cell_of(scene: Scene, pos: [i64; 2]) -> u32 { + let grid = scene.grid(); + let cx = (pos[0] / (scene.cell * ONE)).clamp(0, grid - 1); + let cy = (pos[1] / (scene.cell * ONE)).clamp(0, grid - 1); + u32::try_from(cy * grid + cx).expect("cell index fits u32") +} + +/// One frame of the game. Pure and deterministic: the Echo rule, its +/// footprint and the reference all call it. +pub fn step(scene: Scene, seed: u64, world: &World, input: [i64; 2]) -> World { + let frame = world.frame; + let mut next_id = world.next_id; + let mut bodies: Vec = world.bodies.values().copied().collect(); + + // Input, ageing and expiry. + for body in &mut bodies { + if body.kind == Kind::Player { + body.vel[0] += input[0]; + body.vel[1] += input[1]; + } + if body.kind == Kind::Projectile { + body.ttl = body.ttl.saturating_sub(1); + } + } + bodies.retain(|body| body.kind != Kind::Projectile || body.ttl > 0); + + // Spawns, in emitter order. + let emitters: Vec = bodies + .iter() + .filter(|body| body.kind == Kind::Emitter) + .copied() + .collect(); + for emitter in emitters { + for k in 0..scene.spawn_per_emitter { + let mut rng = SplitMix64::new( + seed ^ frame.rotate_left(20) ^ (u64::from(emitter.id) << 40) ^ u64::from(k), + ); + let dir = |rng: &mut SplitMix64| { + let span = u64::try_from(2 * scene.projectile_speed + 1).unwrap_or(1); + i64::try_from(rng.below(span).unwrap_or(0)).unwrap_or(0) - scene.projectile_speed + }; + let vel = [dir(&mut rng), dir(&mut rng)]; + bodies.push(Body { + id: next_id, + kind: Kind::Projectile, + pos: emitter.pos, + vel, + radius: scene.radius * ONE / 2, + ttl: scene.projectile_ttl, + }); + next_id += 1; + } + } + + // Integration and walls. + let limit = scene.world * ONE; + for body in &mut bodies { + if body.kind == Kind::Emitter { + continue; + } + if body.kind != Kind::Projectile { + body.vel[1] -= scene.gravity; + } + for axis in 0..2 { + body.pos[axis] += body.vel[axis]; + if body.pos[axis] < body.radius { + body.pos[axis] = body.radius; + body.vel[axis] = -body.vel[axis] * scene.restitution / 256; + } else if body.pos[axis] > limit - body.radius { + body.pos[axis] = limit - body.radius; + body.vel[axis] = -body.vel[axis] * scene.restitution / 256; + } + } + } + + // Broadphase: bodies by cell, in body order. + let grid = scene.grid(); + let mut cells: Vec> = vec![Vec::new(); usize::try_from(grid * grid).unwrap_or(0)]; + for (index, body) in bodies.iter().enumerate() { + if body.kind.collides() { + cells[cell_of(scene, body.pos) as usize].push(index); + } + } + + // Narrowphase and response, in canonical order: each body against later + // bodies in its own and neighbouring cells. + let mut contacts = BTreeSet::new(); + for i in 0..bodies.len() { + if !bodies[i].kind.collides() { + continue; + } + let home = i64::from(cell_of(scene, bodies[i].pos)); + let (cx, cy) = (home % grid, home / grid); + let mut partners = Vec::new(); + for ny in (cy - 1).max(0)..=(cy + 1).min(grid - 1) { + for nx in (cx - 1).max(0)..=(cx + 1).min(grid - 1) { + partners.extend( + cells[usize::try_from(ny * grid + nx).unwrap_or(0)] + .iter() + .copied(), + ); + } + } + partners.retain(|&j| j > i); + partners.sort_unstable(); + for j in partners { + let (a, b) = (bodies[i], bodies[j]); + if a.kind == Kind::Projectile && b.kind == Kind::Projectile { + continue; + } + let d = [ + i128::from(b.pos[0] - a.pos[0]), + i128::from(b.pos[1] - a.pos[1]), + ]; + let reach = i128::from(a.radius + b.radius); + let dist2 = d[0] * d[0] + d[1] * d[1]; + if dist2 >= reach * reach { + continue; + } + contacts.insert((a.id.min(b.id), a.id.max(b.id))); + let dist = i128::try_from(u128::try_from(dist2).unwrap_or(0).isqrt()).unwrap_or(0); + let normal = if dist == 0 { + [i128::from(ONE), 0] + } else { + [d[0] * i128::from(ONE) / dist, d[1] * i128::from(ONE) / dist] + }; + let push = (reach - dist) / 2; + let rel = [ + i128::from(b.vel[0] - a.vel[0]), + i128::from(b.vel[1] - a.vel[1]), + ]; + let closing = (rel[0] * normal[0] + rel[1] * normal[1]) / i128::from(ONE); + let impulse = if closing < 0 { + -(256 + i128::from(scene.restitution)) * closing / 512 + } else { + 0 + }; + for axis in 0..2 { + let shift = i64::try_from(normal[axis] * push / i128::from(ONE)).unwrap_or(0); + let kick = i64::try_from(normal[axis] * impulse / i128::from(ONE)).unwrap_or(0); + bodies[i].pos[axis] -= shift; + bodies[j].pos[axis] += shift; + bodies[i].vel[axis] -= kick; + bodies[j].vel[axis] += kick; + } + } + } + + World { + frame: frame + 1, + next_id, + bodies: bodies.into_iter().map(|body| (body.id, body)).collect(), + contacts, + } +} + +// --- Graph identities --------------------------------------------------- + +fn hashed(domain: &[u8], parts: &[&[u8]]) -> [u8; 32] { + let mut hash = blake3::Hasher::new(); + hash.update(domain); + for part in parts { + hash.update(part); + } + hash.finalize().into() +} + +fn root_node() -> NodeId { + make_node_id("f6-root") +} + +fn world_node() -> NodeId { + make_node_id("f6-world") +} + +fn cell_node(cell: u32) -> NodeId { + NodeId(hashed( + b"echo.bench.fixture.f6.cell/v1\0", + &[&cell.to_le_bytes()], + )) +} + +/// The node of body `id`. +pub fn body_node(id: u32) -> NodeId { + NodeId(hashed( + b"echo.bench.fixture.f6.body/v1\0", + &[&id.to_le_bytes()], + )) +} + +fn member_edge(id: u32, cell: u32) -> EdgeId { + EdgeId(hashed( + b"echo.bench.fixture.f6.member/v1\0", + &[&id.to_le_bytes(), &cell.to_le_bytes()], + )) +} + +fn contact_edge(a: u32, b: u32) -> EdgeId { + EdgeId(hashed( + b"echo.bench.fixture.f6.contact/v1\0", + &[&a.to_le_bytes(), &b.to_le_bytes()], + )) +} + +fn body_type() -> TypeId { + make_type_id("bench/f6/body") +} + +fn member_type() -> TypeId { + make_type_id("bench/f6/member") +} + +fn contact_type() -> TypeId { + make_type_id("bench/f6/contact") +} + +fn world_type() -> TypeId { + make_type_id("bench/f6/world") +} + +fn world_bytes(world: &World) -> Vec { + let mut out = Vec::with_capacity(12); + out.extend_from_slice(&world.frame.to_le_bytes()); + out.extend_from_slice(&world.next_id.to_le_bytes()); + out +} + +fn atom(ty: TypeId, bytes: Vec) -> AttachmentValue { + AttachmentValue::Atom(AtomPayload::new(ty, Bytes::from(bytes))) +} + +// --- Reading the world from the graph ------------------------------------ + +/// The reads the step needs, over either view an executor or a footprint +/// gets. +trait WorldReader { + fn attachment_bytes(&mut self, node: &NodeId) -> Option>; + fn targets(&mut self, node: &NodeId, ty: TypeId) -> Vec; +} + +impl WorldReader for GraphView<'_> { + fn attachment_bytes(&mut self, node: &NodeId) -> Option> { + match self.node_attachment(node) { + Some(AttachmentValue::Atom(atom)) => Some(atom.bytes.to_vec()), + _ => None, + } + } + + fn targets(&mut self, node: &NodeId, ty: TypeId) -> Vec { + self.edges_from(node) + .filter(|edge| edge.ty == ty) + .map(|edge| edge.to) + .collect() + } +} + +impl WorldReader for ExecutionGraphView<'_, '_> { + fn attachment_bytes(&mut self, node: &NodeId) -> Option> { + match self.node_attachment(node) { + Some(AttachmentValue::Atom(atom)) => Some(atom.bytes.to_vec()), + _ => None, + } + } + + fn targets(&mut self, node: &NodeId, ty: TypeId) -> Vec { + self.edges_from(node) + .filter(|edge| edge.ty == ty) + .map(|edge| edge.to) + .collect() + } +} + +/// Reads the world back from the graph, or `None` if it is malformed. +fn read_world(reader: &mut impl WorldReader, scene: Scene) -> Option { + let meta = reader.attachment_bytes(&world_node())?; + let frame = u64::from_le_bytes(meta.get(0..8)?.try_into().ok()?); + let next_id = u32::from_le_bytes(meta.get(8..12)?.try_into().ok()?); + let mut bodies = BTreeMap::new(); + let mut contacts = BTreeSet::new(); + let mut nodes = Vec::new(); + for cell in 0..scene.cells() { + nodes.extend(reader.targets(&cell_node(cell), member_type())); + } + // Emitters do not collide but still sit in a cell, so every body is + // reached through its cell. + for node in nodes { + let body = Body::decode(&reader.attachment_bytes(&node)?)?; + bodies.insert(body.id, body); + } + let by_node: BTreeMap = bodies.keys().map(|&id| (body_node(id), id)).collect(); + for &id in bodies.keys() { + for other in reader.targets(&body_node(id), contact_type()) { + contacts.insert((id, *by_node.get(&other)?)); + } + } + Some(World { + frame, + next_id, + bodies, + contacts, + }) +} + +/// The ops that turn `before` into `after` in the graph. +fn diff_ops( + scene: Scene, + warp_id: warp_core::WarpId, + before: &World, + after: &World, +) -> Vec { + let key = |node: NodeId| NodeKey { + warp_id, + local_id: node, + }; + let mut ops = Vec::new(); + for pair in &before.contacts { + if !after.contacts.contains(pair) { + ops.push(WarpOp::DeleteEdge { + warp_id, + from: body_node(pair.0), + edge_id: contact_edge(pair.0, pair.1), + }); + } + } + for (id, old) in &before.bodies { + let old_cell = cell_of(scene, old.pos); + match after.bodies.get(id) { + None => { + ops.push(WarpOp::DeleteEdge { + warp_id, + from: cell_node(old_cell), + edge_id: member_edge(*id, old_cell), + }); + ops.push(WarpOp::DeleteNode { + node: key(body_node(*id)), + }); + } + Some(new) => { + if new != old { + ops.push(WarpOp::SetAttachment { + key: AttachmentKey::node_alpha(key(body_node(*id))), + value: Some(atom(body_type(), new.encode())), + }); + } + let new_cell = cell_of(scene, new.pos); + if new_cell != old_cell { + ops.push(WarpOp::DeleteEdge { + warp_id, + from: cell_node(old_cell), + edge_id: member_edge(*id, old_cell), + }); + ops.push(member_upsert(warp_id, *id, new_cell)); + } + } + } + } + for (id, new) in &after.bodies { + if before.bodies.contains_key(id) { + continue; + } + ops.push(WarpOp::UpsertNode { + node: key(body_node(*id)), + record: NodeRecord { ty: body_type() }, + }); + ops.push(WarpOp::SetAttachment { + key: AttachmentKey::node_alpha(key(body_node(*id))), + value: Some(atom(body_type(), new.encode())), + }); + ops.push(member_upsert(warp_id, *id, cell_of(scene, new.pos))); + } + for pair in &after.contacts { + if !before.contacts.contains(pair) { + ops.push(WarpOp::UpsertEdge { + warp_id, + record: EdgeRecord { + id: contact_edge(pair.0, pair.1), + from: body_node(pair.0), + to: body_node(pair.1), + ty: contact_type(), + }, + }); + } + } + ops.push(WarpOp::SetAttachment { + key: AttachmentKey::node_alpha(key(world_node())), + value: Some(atom(world_type(), world_bytes(after))), + }); + ops +} + +fn member_upsert(warp_id: warp_core::WarpId, id: u32, cell: u32) -> WarpOp { + WarpOp::UpsertEdge { + warp_id, + record: EdgeRecord { + id: member_edge(id, cell), + from: cell_node(cell), + to: body_node(id), + ty: member_type(), + }, + } +} + +// --- The rule ------------------------------------------------------------- + +/// A step intent's variables. The intent carries the scene's dimensions and +/// the seed as well as the frame and its input, so the installed rule needs +/// no state outside the graph and the intent: Echo's crates admit no global +/// state, and scaled test scenes share their full scene's index. +fn vars(scene: Scene, seed: u64, frame: u64, input: [i64; 2]) -> Vec { + let mut out = Vec::with_capacity(VARS_BYTES); + out.push(scene.index); + out.push(u8::from(scene.player)); + for value in [frame, seed, scene.frames] { + out.extend_from_slice(&value.to_le_bytes()); + } + for value in [ + scene.bodies, + scene.emitters, + scene.spawn_per_emitter, + scene.projectile_ttl, + ] { + out.extend_from_slice(&value.to_le_bytes()); + } + for value in [ + input[0], + input[1], + scene.radius, + scene.world, + scene.cell, + scene.gravity, + scene.restitution, + scene.initial_speed, + scene.projectile_speed, + ] { + out.extend_from_slice(&value.to_le_bytes()); + } + out +} + +const VARS_BYTES: usize = 2 + 8 * 3 + 4 * 4 + 8 * 9; + +/// Parses a step intent's variables: the scene, the seed, the frame and its +/// input. +fn parse(vars: &[u8]) -> Option<(Scene, u64, u64, [i64; 2])> { + if vars.len() != VARS_BYTES { + return None; + } + let name = SCENES.get(usize::from(vars[0]))?.name; + let player = match vars[1] { + 0 => false, + 1 => true, + _ => return None, + }; + let u64_at = |at: usize| u64::from_le_bytes(vars[at..at + 8].try_into().unwrap_or([0; 8])); + let u32_at = |at: usize| u32::from_le_bytes(vars[at..at + 4].try_into().unwrap_or([0; 4])); + let i64_at = |at: usize| i64::from_le_bytes(vars[at..at + 8].try_into().unwrap_or([0; 8])); + let (frame, seed) = (u64_at(2), u64_at(10)); + let words = 26; + let longs = words + 16; + let scene = Scene { + name, + index: vars[0], + bodies: u32_at(words), + radius: i64_at(longs + 16), + world: i64_at(longs + 24), + cell: i64_at(longs + 32), + gravity: i64_at(longs + 40), + restitution: i64_at(longs + 48), + initial_speed: i64_at(longs + 56), + player, + emitters: u32_at(words + 4), + spawn_per_emitter: u32_at(words + 8), + projectile_ttl: u32_at(words + 12), + projectile_speed: i64_at(longs + 64), + frames: u64_at(18), + }; + if scene.cell <= 0 || scene.world < scene.cell { + return None; + } + Some((scene, seed, frame, [i64_at(longs), i64_at(longs + 8)])) +} + +fn matches(view: GraphView<'_>, scope: &NodeId) -> bool { + warp_core::eint_vars_for_op(view, scope, OP_ID) + .and_then(parse) + .is_some() +} + +fn execute(view: &mut ExecutionGraphView<'_, '_>, scope: &NodeId, delta: &mut TickDelta) { + let Some((scene, seed, frame, input)) = + warp_core::observed_eint_vars_for_op(view, scope, OP_ID).and_then(parse) + else { + return; + }; + let Some(before) = read_world(view, scene) else { + return; + }; + if before.frame != frame { + return; + } + let after = step(scene, seed, &before, input); + for op in diff_ops(scene, view.warp_id(), &before, &after) { + delta.push(op); + } +} + +fn footprint(view: GraphView<'_>, scope: &NodeId) -> Footprint { + let mut footprint = warp_core::runtime_ingress_eint_read_footprint(view, scope); + let Some((scene, seed, frame, input)) = + warp_core::eint_vars_for_op(view, scope, OP_ID).and_then(parse) + else { + return footprint; + }; + let warp_id = view.warp_id(); + let mut reader = view; + let Some(before) = read_world(&mut reader, scene) else { + return footprint; + }; + // Reads: the world, every cell, every body. + let alpha = |node: NodeId| { + AttachmentKey::node_alpha(NodeKey { + warp_id, + local_id: node, + }) + }; + footprint.n_read.insert_with_warp(warp_id, world_node()); + footprint.a_read.insert(alpha(world_node())); + for cell in 0..scene.cells() { + footprint.n_read.insert_with_warp(warp_id, cell_node(cell)); + } + for &id in before.bodies.keys() { + footprint.n_read.insert_with_warp(warp_id, body_node(id)); + footprint.a_read.insert(alpha(body_node(id))); + } + if before.frame != frame { + return footprint; + } + // Writes: exactly what the step will write, found by running it. + let after = step(scene, seed, &before, input); + for op in diff_ops(scene, warp_id, &before, &after) { + match op { + WarpOp::SetAttachment { key, .. } => { + footprint.a_write.insert(key); + } + WarpOp::UpsertNode { node, .. } | WarpOp::DeleteNode { node } => { + footprint.n_write.insert(node); + footprint.a_write.insert(AttachmentKey::node_alpha(node)); + } + WarpOp::UpsertEdge { warp_id, record } => { + footprint.n_write.insert_with_warp(warp_id, record.from); + footprint.e_write.insert_with_warp(warp_id, record.id); + } + WarpOp::DeleteEdge { + warp_id, + from, + edge_id, + } => { + footprint.n_write.insert_with_warp(warp_id, from); + footprint.e_write.insert_with_warp(warp_id, edge_id); + footprint + .a_write + .insert(AttachmentKey::edge_beta(warp_core::EdgeKey { + warp_id, + local_id: edge_id, + })); + } + _ => {} + } + } + footprint +} + +fn rule() -> RewriteRule { + RewriteRule { + id: make_type_id(RULE_ID_LABEL).0, + name: RULE_NAME, + left: PatternGraph { nodes: vec![] }, + matcher: matches, + executor: RuleExecutor::observed(execute), + compute_footprint: footprint, + factor_mask: 0, + conflict_policy: ConflictPolicy::Abort, + join_fn: None, + } +} + +static STEP_ARGS: &[ArgDef] = &[ArgDef { + name: "input", + ty: "StepInput", + required: true, + list: false, +}]; + +static OPS: &[OpDef] = &[OpDef { + kind: OpKind::Mutation, + name: "step", + op_id: OP_ID, + args: STEP_ARGS, + result_ty: "Frame", + directives_json: "{}", + footprint_certificate: None, +}]; + +struct Registry; + +impl RegistryProvider for Registry { + fn info(&self) -> RegistryInfo { + RegistryInfo { + echo_abi_version: 1, + codec_id: "cbor-canon-v1", + registry_version: 1, + schema_sha256_hex: SCHEMA_SHA256_HEX, + wesley_generator_version: "echo-wesley-gen/0.1.0", + helper_api_version: 1, + } + } + + fn op_by_id(&self, op_id: u32) -> Option<&'static OpDef> { + OPS.iter().find(|op| op.op_id == op_id) + } + + fn all_ops(&self) -> &'static [OpDef] { + OPS + } + + fn all_enums(&self) -> &'static [echo_registry_api::EnumDef] { + &[] + } + + fn all_objects(&self) -> &'static [ObjectDef] { + &[] + } +} + +fn package(registry: &Registry) -> InstalledContractPackage<'_> { + InstalledContractPackage { + identity: ContractPackageIdentity { + package_name: "bench-fixture-f6", + package_version: "1.0.0", + artifact_hash_hex: "f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6f6", + }, + registry, + verification_policy: ContractArtifactVerificationPolicy { + echo_abi_version: 1, + codec_id: "cbor-canon-v1", + registry_version: 1, + schema_sha256_hex: SCHEMA_SHA256_HEX, + wesley_generator_version: "echo-wesley-gen/0.1.0", + helper_api_version: 1, + footprint_certificates: &[], + require_mutation_footprint_certificates: false, + }, + mutation_handlers: vec![ContractMutationHandler { + op_id: OP_ID, + rule: rule(), + }], + inverse_handlers: vec![], + query_observers: vec![], + } +} + +/// The initial worldline state of `world`. +pub fn initial_state(scene: Scene, world: &World) -> WorldlineState { + let warp_id = make_warp_id(WARP); + let mut store = GraphStore::new(warp_id); + let root = root_node(); + store.insert_node( + root, + NodeRecord { + ty: make_type_id("bench/f6/root"), + }, + ); + store.insert_node(world_node(), NodeRecord { ty: world_type() }); + store.set_node_attachment(world_node(), Some(atom(world_type(), world_bytes(world)))); + store.insert_edge( + root, + EdgeRecord { + id: warp_core::make_edge_id("f6-root-world"), + from: root, + to: world_node(), + ty: make_type_id("bench/f6/world-of"), + }, + ); + for cell in 0..scene.cells() { + store.insert_node( + cell_node(cell), + NodeRecord { + ty: make_type_id("bench/f6/cell"), + }, + ); + store.insert_edge( + world_node(), + EdgeRecord { + id: EdgeId(hashed( + b"echo.bench.fixture.f6.cell-of/v1\0", + &[&cell.to_le_bytes()], + )), + from: world_node(), + to: cell_node(cell), + ty: make_type_id("bench/f6/cell-of"), + }, + ); + } + for body in world.bodies.values() { + store.insert_node(body_node(body.id), NodeRecord { ty: body_type() }); + store.set_node_attachment(body_node(body.id), Some(atom(body_type(), body.encode()))); + let cell = cell_of(scene, body.pos); + store.insert_edge( + cell_node(cell), + EdgeRecord { + id: member_edge(body.id, cell), + from: cell_node(cell), + to: body_node(body.id), + ty: member_type(), + }, + ); + } + for &(a, b) in &world.contacts { + store.insert_edge( + body_node(a), + EdgeRecord { + id: contact_edge(a, b), + from: body_node(a), + to: body_node(b), + ty: contact_type(), + }, + ); + } + WorldlineState::from_root_store(store, root).expect("F6 root state") +} + +// --- Running a scene ------------------------------------------------------- + +/// What one scene run produced, measured and observed. +#[derive(Clone, Debug, Default)] +pub struct Observation { + /// Work accounting: one step intent per frame. + pub accounting: Accounting, + /// Frames that committed exactly one tick. + pub one_tick_frames: u64, + /// The world read back from the live graph at the end. + pub final_world: Option, + /// The reference world after the same frames. + pub reference_world: World, + /// Live state root at the end. + pub live_root: [u8; 32], + /// State root rebuilt by applying retained patches. + pub replay_root: Option<[u8; 32]>, + /// Bodies seen by the per-frame render read, summed over frames. + pub rendered: u64, + /// Peak live bodies and contacts in the reference. + pub peak_bodies: usize, + /// Peak contacts in the reference. + pub peak_contacts: usize, + /// Measured durations. + pub timings: Timings, +} + +/// Measured durations, each with a declared boundary. +#[derive(Clone, Debug, Default)] +pub struct Timings { + /// Host, runtime and package set-up plus initial scene construction. + pub startup: Duration, + /// Per frame: submit, admit, tick, and the render read. + pub frames: Vec, + /// Per frame: the render read alone. + pub render: Vec, + /// Dropping the host and everything it owns. + pub teardown: Duration, + /// Per frame: the reference step alone, as the game's own cost. + pub physics: Vec, + /// Replay of the whole run by applying retained patches. + pub replay: Duration, +} + +/// Runs `scene` under the legacy commitment profile. +pub fn run(scene: Scene, seed: u64) -> Observation { + run_scene(scene, seed, |state| state) +} + +/// Runs `scene` on a worldline with commitment profile `profile`. +#[cfg(feature = "application_root_profile")] +pub fn run_with_profile( + scene: Scene, + seed: u64, + profile: warp_core::application_root::CommitmentProfile, +) -> Observation { + run_scene(scene, seed, move |state| { + state.with_commitment_profile(profile) + }) +} + +fn run_scene( + scene: Scene, + seed: u64, + profile: impl FnOnce(WorldlineState) -> WorldlineState, +) -> Observation { + let mut obs = Observation::default(); + let start_world = initial_world(scene, seed); + let inputs: Vec<[i64; 2]> = (0..scene.frames).map(|f| input(scene, seed, f)).collect(); + + // The reference: the game alone, timed per frame. + let mut reference = start_world.clone(); + for frame_input in &inputs { + let started = Instant::now(); + reference = step(scene, seed, &reference, *frame_input); + obs.timings.physics.push(started.elapsed()); + obs.peak_bodies = obs.peak_bodies.max(reference.bodies.len()); + obs.peak_contacts = obs.peak_contacts.max(reference.contacts.len()); + } + obs.reference_world = reference; + + // Start-up. + let started = Instant::now(); + let worldline_id = WorldlineId::from_bytes(WORLDLINE); + let initial = profile(initial_state(scene, &start_world)); + let base = initial.clone(); + let mut runtime = WorldlineRuntime::new(); + runtime + .register_worldline(worldline_id, initial) + .expect("worldline registers"); + runtime + .register_writer_head(WriterHead::with_routing( + WriterHeadKey { + worldline_id, + head_id: make_head_id("f6-game"), + }, + PlaybackMode::Play, + InboxPolicy::AcceptAll, + None, + true, + )) + .expect("writer head registers"); + let mut engine_store = GraphStore::default(); + let engine_root = make_node_id("f6-engine-root"); + engine_store.insert_node( + engine_root, + NodeRecord { + ty: make_type_id("bench/f6/engine-root"), + }, + ); + let engine = EngineBuilder::new(engine_store, engine_root) + .scheduler(SchedulerKind::Radix) + .workers(1) + .build(); + let mut host = TrustedRuntimeHost::new(runtime, engine).expect("host initializes"); + let registry = Registry; + host.register_contract_package(package(®istry)) + .expect("package installs"); + obs.timings.startup = started.elapsed(); + + // Frames. + let warp_id = make_warp_id(WARP); + let mut submissions = Vec::with_capacity(inputs.len()); + for (frame, frame_input) in inputs.iter().enumerate() { + let frame_started = Instant::now(); + obs.accounting.occurrences += 1; + obs.accounting.delivery_attempts += 1; + let envelope = IngressEnvelope::local_intent( + IngressTarget::DefaultWriter { worldline_id }, + make_intent_kind("echo.intent/eint-v1"), + echo_wasm_abi::pack_intent_v1(OP_ID, &vars(scene, seed, frame as u64, *frame_input)) + .expect("intent packs"), + ); + let Ok(submission) = host.app().submit_intent(envelope) else { + continue; + }; + obs.accounting.submitted += 1; + if host + .admit_installed_contract_submission(submission.submission_id) + .is_ok() + { + obs.accounting.admitted += 1; + } + let steps = host.tick_once().expect("tick runs"); + if steps.len() == 1 && steps[0].admitted_count == 1 { + obs.one_tick_frames += 1; + } + // The render read: every body's state, as a renderer would copy it. + let render_started = Instant::now(); + let live = host + .runtime() + .worldlines() + .get(&worldline_id) + .expect("worldline exists"); + if let Some(store) = live.state().store(&warp_id) { + let mut seen = 0_u64; + let mut checksum = 0_i64; + for cell in 0..scene.cells() { + for edge in store.edges_from(&cell_node(cell)) { + if let Some(AttachmentValue::Atom(atom)) = store.node_attachment(&edge.to) { + if let Some(body) = Body::decode(&atom.bytes) { + checksum = checksum.wrapping_add(body.pos[0] ^ body.pos[1]); + seen += 1; + } + } + } + } + std::hint::black_box(checksum); + obs.rendered += seen; + } + obs.timings.render.push(render_started.elapsed()); + obs.timings.frames.push(frame_started.elapsed()); + submissions.push(submission.submission_id); + } + + // Outcomes and the final world, outside measurement. + for submission in &submissions { + match host.app().observe_intent_outcome(submission) { + IntentOutcome::Applied { .. } => obs.accounting.committed += 1, + IntentOutcome::Pending { .. } => obs.accounting.unfinished += 1, + _ => obs.accounting.refused += 1, + } + } + { + let live = host + .runtime() + .worldlines() + .get(&worldline_id) + .expect("worldline exists"); + obs.live_root = live.state().state_root(); + if let Some(store) = live.state().store(&warp_id) { + let mut view = GraphView::new(store); + obs.final_world = read_world(&mut view, scene); + } + } + let started = Instant::now(); + let replayed = host + .provenance() + .replay_worldline_state(worldline_id, &base); + obs.timings.replay = started.elapsed(); + obs.replay_root = replayed.ok().map(|state| state.state_root()); + + // Teardown. + let started = Instant::now(); + drop(host); + obs.timings.teardown = started.elapsed(); + obs +} + +/// Validates an observation. Expected counts come from the scene. +pub fn validate(scene: Scene, obs: &Observation) -> ResultClass { + if obs.accounting.committed + obs.accounting.refused < scene.frames { + return ResultClass::Incomplete(format!( + "{} of {} frames finished", + obs.accounting.committed + obs.accounting.refused, + scene.frames + )); + } + let mut checks = Checks::default(); + checks.check( + obs.accounting.committed == scene.frames, + format!( + "committed {} of {} frames", + obs.accounting.committed, scene.frames + ), + ); + checks.check(obs.accounting.refused == 0, "a frame was refused"); + checks.check( + obs.one_tick_frames == scene.frames, + format!( + "{} of {} frames committed exactly one tick", + obs.one_tick_frames, scene.frames + ), + ); + checks.check( + obs.final_world.as_ref() == Some(&obs.reference_world), + "the world in the graph differs from the reference simulation", + ); + checks.check( + obs.replay_root == Some(obs.live_root), + "replay by applying retained patches did not reproduce the live root", + ); + checks.class() +} diff --git a/crates/warp-core/benches/support/fixtures/mod.rs b/crates/warp-core/benches/support/fixtures/mod.rs index 2ae186db..f6989779 100644 --- a/crates/warp-core/benches/support/fixtures/mod.rs +++ b/crates/warp-core/benches/support/fixtures/mod.rs @@ -10,7 +10,9 @@ //! - [`outcome`]: result classes and work accounting. //! - [`star`]: control C1, the star scene `action_scene` builds. //! - [`think_append`]: fixture F5, append and replay through the trusted host. +//! - [`game`]: fixture F6, real-time game scenes run frame by frame. +pub mod game; pub mod manifest; pub mod outcome; pub mod prng; diff --git a/crates/warp-core/tests/scene_fixture_tests.rs b/crates/warp-core/tests/scene_fixture_tests.rs index 42613921..07d768f5 100644 --- a/crates/warp-core/tests/scene_fixture_tests.rs +++ b/crates/warp-core/tests/scene_fixture_tests.rs @@ -16,7 +16,7 @@ mod fixtures; use fixtures::manifest::hex32; use fixtures::outcome::ResultClass; use fixtures::prng::SplitMix64; -use fixtures::{star, think_append}; +use fixtures::{game, star, think_append}; /// SplitMix64 from seed 1234567: the reference sequence, cross-checked with /// an independent Python implementation of Vigna's code. @@ -245,3 +245,34 @@ fn f5_rejects_missing_or_failed_application_root_parity() { ); } } + +/// Small F6 scenes run validly in a debug build, where footprint enforcement +/// checks that the step declares every read and write it makes. The pile runs +/// long enough to land and pile up, so contact edges are added and removed; +/// the bullets run past a projectile lifetime, so bodies despawn. +#[test] +fn f6_small_scenes_run_validly() { + for scene in [ + game::ARENA.scaled(1, 8, 30), + game::PILE.scaled(1, 16, 110), + game::BULLETS.scaled(1, 8, 70), + ] { + let obs = game::run(scene, 6); + assert_eq!( + game::validate(scene, &obs), + ResultClass::Valid, + "{}", + scene.name + ); + assert!(obs.rendered > 0, "{} rendered nothing", scene.name); + } + let pile = game::PILE.scaled(1, 16, 110); + let obs = game::run(pile, 6); + assert!(obs.peak_contacts > 0, "the pile never made contact"); + let bullets = game::BULLETS.scaled(1, 8, 70); + let obs = game::run(bullets, 6); + assert!( + obs.reference_world.next_id as usize > obs.reference_world.bodies.len(), + "no projectile despawned" + ); +} From 9a0c9bd3ca4b6c271f269e8d5c8c4939531cccf6 Mon Sep 17 00:00:00 2001 From: James Ross Date: Tue, 20 Oct 2026 11:47:02 -0700 Subject: [PATCH 2/4] Bench: F6 passes clippy: integer percentiles, iterate the normal clippy -D warnings rejected the f64-to-usize cast in the percentile index and the range loop that indexed the contact normal. Percentiles now take thousandths and round half up in integers; the impulse loop iterates the normal. The physics is unchanged: the same values, in the same order. Co-Authored-By: Claude Opus 5.5 --- crates/warp-core/benches/game_scenes.rs | 26 ++++++++++--------- .../benches/support/fixtures/game.rs | 6 ++--- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/crates/warp-core/benches/game_scenes.rs b/crates/warp-core/benches/game_scenes.rs index 38157038..093bc851 100644 --- a/crates/warp-core/benches/game_scenes.rs +++ b/crates/warp-core/benches/game_scenes.rs @@ -35,10 +35,12 @@ fn ms(duration: Duration) -> f64 { duration.as_secs_f64() * 1e3 } -fn percentile(samples: &[Duration], p: f64) -> f64 { +/// The sample at `per_mille` thousandths of the way through the sorted +/// samples, rounding half up. +fn percentile(samples: &[Duration], per_mille: usize) -> f64 { let mut sorted = samples.to_vec(); sorted.sort_unstable(); - let index = ((sorted.len().saturating_sub(1)) as f64 * p).round() as usize; + let index = (sorted.len().saturating_sub(1) * per_mille + 500) / 1000; sorted.get(index).copied().map_or(0.0, ms) } @@ -85,16 +87,16 @@ fn report(scene: Scene, profile: &str, obs: &Observation) { obs.peak_bodies, obs.peak_contacts, ms(t.startup), - percentile(&t.frames, 0.5), - percentile(&t.frames, 0.9), - percentile(&t.frames, 0.99), - percentile(&t.frames, 1.0), - percentile(&t.render, 0.5), - percentile(&t.physics, 0.5), - percentile(&in_frame_physics, 0.5), - percentile(&echo, 0.5), - percentile(&echo, 0.99), - percentile(&echo, 1.0), + percentile(&t.frames, 500), + percentile(&t.frames, 900), + percentile(&t.frames, 990), + percentile(&t.frames, 1000), + percentile(&t.render, 500), + percentile(&t.physics, 500), + percentile(&in_frame_physics, 500), + percentile(&echo, 500), + percentile(&echo, 990), + percentile(&echo, 1000), ms(t.teardown), ms(t.replay), ); diff --git a/crates/warp-core/benches/support/fixtures/game.rs b/crates/warp-core/benches/support/fixtures/game.rs index 2a8e7526..3c4598d4 100644 --- a/crates/warp-core/benches/support/fixtures/game.rs +++ b/crates/warp-core/benches/support/fixtures/game.rs @@ -534,9 +534,9 @@ pub fn step(scene: Scene, seed: u64, world: &World, input: [i64; 2]) -> World { } else { 0 }; - for axis in 0..2 { - let shift = i64::try_from(normal[axis] * push / i128::from(ONE)).unwrap_or(0); - let kick = i64::try_from(normal[axis] * impulse / i128::from(ONE)).unwrap_or(0); + for (axis, n) in normal.iter().enumerate() { + let shift = i64::try_from(n * push / i128::from(ONE)).unwrap_or(0); + let kick = i64::try_from(n * impulse / i128::from(ONE)).unwrap_or(0); bodies[i].pos[axis] -= shift; bodies[j].pos[axis] += shift; bodies[i].vel[axis] -= kick; From 9b59ccf60b9509cf94df31305fdb14604774c50f Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 21 Oct 2026 04:47:12 -0700 Subject: [PATCH 3/4] Bench: F6 reports only measured phases; narrow its oracle claim The headline F6 line now carries only measured durations: frame p50/p90/p99/max, frames over the 60 Hz budget, start-up, the render read, the reference step timed alone (renamed from physics_p50_ms to reference_step_p50_ms), teardown and replay. The old echo_model columns move to a separate F6-residual line as frame_minus_2x_reference_step_estimate_{p50,p99,max}_ms. Its comment says it is a residual estimate, neither Echo's cost nor an upper bound on it: the reference step is timed in a separate pass under different cache and measurement conditions, and the residual also holds the rule's own world read and diff. The module and step docs no longer say a valid run means Echo stored "what the game computed" in a sense that covers physics. Sharing step() between the rule and the reference checks that hosted execution stores and recovers the reference result; a bug in step() occurs on both sides. Two hand-computed scenes check step() independently and, through the new game::run_from, check the hosted graph too: an elastic head-on collision that adds and then removes a contact edge, and a projectile that despawns while in contact and takes its contact edge with it. Under a mutation of step() (impulse sign flipped on one body, projectiles expire a frame early) both fail while f6_small_scenes_run_validly still passes. Co-Authored-By: Claude Opus 5.5 --- crates/warp-core/benches/game_scenes.rs | 56 ++++--- .../benches/support/fixtures/game.rs | 31 +++- crates/warp-core/tests/scene_fixture_tests.rs | 156 +++++++++++++++++- 3 files changed, 210 insertions(+), 33 deletions(-) diff --git a/crates/warp-core/benches/game_scenes.rs b/crates/warp-core/benches/game_scenes.rs index 093bc851..232cd629 100644 --- a/crates/warp-core/benches/game_scenes.rs +++ b/crates/warp-core/benches/game_scenes.rs @@ -16,10 +16,12 @@ //! ``` //! //! Each scene runs once per commitment profile. A frame is one submitted and -//! admitted step intent, one tick, and a render read of every body; the -//! report gives frame percentiles, how many frames fit a 60 Hz budget, and -//! the game's own physics time for comparison. Only a valid run reports -//! figures. +//! admitted step intent, one tick, and a render read of every body. The +//! headline `F6` line reports only measured durations: frame latency +//! percentiles and maximum, frames over the 60 Hz budget, start-up, the +//! render read, the reference step timed alone outside Echo, teardown and +//! replay. A separate `F6-residual` line reports a derived estimate, described +//! where it is computed. Only a valid run reports figures. #[path = "support/fixtures/mod.rs"] mod fixtures; @@ -63,25 +65,11 @@ fn report(scene: Scene, profile: &str, obs: &Observation) { .iter() .filter(|frame| **frame > FRAME_BUDGET) .count(); - // Echo's share of each frame, modeled: the frame less the game's step, - // which runs twice inside it (once in the rule's footprint, once in its - // executor; the engine computes a candidate's footprint once). The step - // is pure, so each in-frame run does the work the reference timed for - // the same frame. The render read stays in, as the host's read of Echo - // state. - let in_frame_physics: Vec = t.physics.iter().map(|step| *step * 2).collect(); - let echo: Vec = t - .frames - .iter() - .zip(&in_frame_physics) - .map(|(frame, physics)| frame.saturating_sub(*physics)) - .collect(); println!( "F6 scene={} profile={profile} frames={} peak_bodies={} peak_contacts={} \ startup_ms={:.2} frame_p50_ms={:.3} frame_p90_ms={:.3} frame_p99_ms={:.3} \ - frame_max_ms={:.3} over_16.7ms={over} render_p50_ms={:.3} physics_p50_ms={:.3} \ - in_frame_physics_model_p50_ms={:.3} echo_model_p50_ms={:.3} echo_model_p99_ms={:.3} echo_model_max_ms={:.3} \ - teardown_ms={:.2} replay_ms={:.1}", + frame_max_ms={:.3} over_16.7ms={over} render_p50_ms={:.3} \ + reference_step_p50_ms={:.3} teardown_ms={:.2} replay_ms={:.1}", scene.name, t.frames.len(), obs.peak_bodies, @@ -93,13 +81,33 @@ fn report(scene: Scene, profile: &str, obs: &Observation) { percentile(&t.frames, 1000), percentile(&t.render, 500), percentile(&t.physics, 500), - percentile(&in_frame_physics, 500), - percentile(&echo, 500), - percentile(&echo, 990), - percentile(&echo, 1000), ms(t.teardown), ms(t.replay), ); + // A derived residual, not a measurement: per frame, the frame time less + // twice the reference step time for that frame (the rule runs the step + // once in its footprint and once in its executor). It is neither Echo's + // cost nor an upper bound on it. The reference step is timed in a + // separate pass outside Echo, so cache state and measurement conditions + // differ from the in-frame runs, and the residual also holds the rule's + // own work beyond the step: reading the world out of the graph and + // diffing it. Attribution of the residual is uncertain. + let residual: Vec = t + .frames + .iter() + .zip(&t.physics) + .map(|(frame, step)| frame.saturating_sub(*step * 2)) + .collect(); + println!( + "F6-residual scene={} profile={profile} \ + frame_minus_2x_reference_step_estimate_p50_ms={:.3} \ + frame_minus_2x_reference_step_estimate_p99_ms={:.3} \ + frame_minus_2x_reference_step_estimate_max_ms={:.3}", + scene.name, + percentile(&residual, 500), + percentile(&residual, 990), + percentile(&residual, 1000), + ); } fn main() { diff --git a/crates/warp-core/benches/support/fixtures/game.rs b/crates/warp-core/benches/support/fixtures/game.rs index 3c4598d4..f5b39fe4 100644 --- a/crates/warp-core/benches/support/fixtures/game.rs +++ b/crates/warp-core/benches/support/fixtures/game.rs @@ -13,8 +13,12 @@ //! bodies into a broadphase grid, and resolves overlapping pairs with a //! position correction and an impulse, in canonical order. The same pure //! [`step`] function drives both the Echo rule and the in-memory reference -//! the fixture validates against, so a valid run means Echo stored exactly -//! what the game computed. +//! the fixture validates against. A valid run therefore shows that hosted +//! execution stored, and the graph gave back, exactly what the reference path +//! computed. It does not show that [`step`] is correct physics: a bug in +//! [`step`] occurs on both sides and still validates. Small hand-computed +//! scenes in the tests check collision, despawn and contact edges +//! independently of [`step`]. //! //! **The graph.** A root links a world node, which links one node per grid //! cell. Each body is a node with its state as an attachment, and an edge @@ -405,7 +409,9 @@ fn cell_of(scene: Scene, pos: [i64; 2]) -> u32 { } /// One frame of the game. Pure and deterministic: the Echo rule, its -/// footprint and the reference all call it. +/// footprint and the reference all call it, so comparing the hosted result +/// with the reference checks Echo's storage and recovery of this function's +/// output, not this function's correctness. pub fn step(scene: Scene, seed: u64, world: &World, input: [i64; 2]) -> World { let frame = world.frame; let mut next_id = world.next_id; @@ -1139,7 +1145,9 @@ pub struct Observation { pub one_tick_frames: u64, /// The world read back from the live graph at the end. pub final_world: Option, - /// The reference world after the same frames. + /// The reference world after the same frames: [`step`] run directly, + /// outside Echo. It shares [`step`] with the rule, so it is an oracle + /// for hosting, not for the physics. pub reference_world: World, /// Live state root at the end. pub live_root: [u8; 32], @@ -1166,7 +1174,8 @@ pub struct Timings { pub render: Vec, /// Dropping the host and everything it owns. pub teardown: Duration, - /// Per frame: the reference step alone, as the game's own cost. + /// Per frame: the reference step alone, timed outside Echo, before the + /// hosted run. pub physics: Vec, /// Replay of the whole run by applying retained patches. pub replay: Duration, @@ -1174,7 +1183,13 @@ pub struct Timings { /// Runs `scene` under the legacy commitment profile. pub fn run(scene: Scene, seed: u64) -> Observation { - run_scene(scene, seed, |state| state) + run_scene(scene, seed, initial_world(scene, seed), |state| state) +} + +/// Runs `scene` from `start` instead of the scene's generated world, under +/// the legacy commitment profile. Tests use it for hand-built worlds. +pub fn run_from(scene: Scene, seed: u64, start: World) -> Observation { + run_scene(scene, seed, start, |state| state) } /// Runs `scene` on a worldline with commitment profile `profile`. @@ -1184,7 +1199,7 @@ pub fn run_with_profile( seed: u64, profile: warp_core::application_root::CommitmentProfile, ) -> Observation { - run_scene(scene, seed, move |state| { + run_scene(scene, seed, initial_world(scene, seed), move |state| { state.with_commitment_profile(profile) }) } @@ -1192,10 +1207,10 @@ pub fn run_with_profile( fn run_scene( scene: Scene, seed: u64, + start_world: World, profile: impl FnOnce(WorldlineState) -> WorldlineState, ) -> Observation { let mut obs = Observation::default(); - let start_world = initial_world(scene, seed); let inputs: Vec<[i64; 2]> = (0..scene.frames).map(|f| input(scene, seed, f)).collect(); // The reference: the game alone, timed per frame. diff --git a/crates/warp-core/tests/scene_fixture_tests.rs b/crates/warp-core/tests/scene_fixture_tests.rs index 07d768f5..eb86f22a 100644 --- a/crates/warp-core/tests/scene_fixture_tests.rs +++ b/crates/warp-core/tests/scene_fixture_tests.rs @@ -4,7 +4,9 @@ //! //! These run in the ordinary test suite: generator vectors, the separation of //! workload and expectation identities, C1 parity with `action_scene`, one -//! validated F5-S execution, and validator canaries that must be invalid. +//! validated F5-S execution, validator canaries that must be invalid, small +//! validated F6 scenes, and hand-computed F6 scenes that check collision, +//! despawn and contact edges independently of the game's own step. #![cfg(all(feature = "native_rule_bootstrap", feature = "trusted_runtime"))] #![allow(missing_docs, dead_code, clippy::expect_used, clippy::panic)] @@ -276,3 +278,155 @@ fn f6_small_scenes_run_validly() { "no projectile despawned" ); } + +/// A 64-unit box with 16-unit cells, no gravity, fully elastic, no player +/// and no emitters: the dimensions of the hand-computed F6 scenes below. +const HAND: game::Scene = game::Scene { + name: "hand", + index: 0, + bodies: 0, + radius: 1, + world: 64, + cell: 16, + gravity: 0, + restitution: 256, + initial_speed: 0, + player: false, + emitters: 0, + spawn_per_emitter: 0, + projectile_ttl: 0, + projectile_speed: 0, + frames: 0, +}; + +fn hand_body(id: u32, kind: game::Kind, pos: [i64; 2], vel: [i64; 2], radius: i64) -> game::Body { + game::Body { + id, + kind, + pos, + vel, + radius, + ttl: 0, + } +} + +fn hand_world( + frame: u64, + next_id: u32, + bodies: &[game::Body], + contacts: &[(u32, u32)], +) -> game::World { + game::World { + frame, + next_id, + bodies: bodies.iter().map(|body| (body.id, *body)).collect(), + contacts: contacts.iter().copied().collect(), + } +} + +/// Checks `expected` frame by frame, independently of `game::step`: the +/// expected worlds are worked out by hand from the physics, not by running +/// the step. Each prefix also runs hosted in Echo (a debug build, so with +/// footprint enforcement) and the world read back from the graph, including +/// its contact edges, must equal the hand-computed one. +fn check_hand_scene(start: &game::World, expected: &[game::World]) { + let mut world = start.clone(); + for (frame, want) in expected.iter().enumerate() { + world = game::step(HAND, 0, &world, [0, 0]); + assert_eq!(&world, want, "step, frame {}", frame + 1); + let scene = game::Scene { + frames: frame as u64 + 1, + ..HAND + }; + let obs = game::run_from(scene, 0, start.clone()); + assert_eq!( + game::validate(scene, &obs), + ResultClass::Valid, + "frame {}", + frame + 1 + ); + assert_eq!( + obs.final_world.as_ref(), + Some(want), + "graph, frame {}", + frame + 1 + ); + } +} + +/// Two equal unit circles approach head-on at one unit per frame each. After +/// integration their centres are one unit apart against a reach of two: they +/// touch. The correction moves each half the overlap apart (to 10.5 and +/// 12.5), and a fully elastic impulse between equal bodies swaps their +/// velocities. The contact edge appears; one frame later they have moved +/// apart to 9.5 and 13.5 and the edge is gone. +#[test] +fn f6_hand_collision_swaps_velocities_and_adds_then_removes_the_contact() { + use game::{Kind::Dynamic, ONE}; + let y = 10 * ONE; + let start = hand_world( + 0, + 2, + &[ + hand_body(0, Dynamic, [10 * ONE, y], [ONE, 0], ONE), + hand_body(1, Dynamic, [13 * ONE, y], [-ONE, 0], ONE), + ], + &[], + ); + let touching = hand_world( + 1, + 2, + &[ + hand_body(0, Dynamic, [10 * ONE + ONE / 2, y], [-ONE, 0], ONE), + hand_body(1, Dynamic, [12 * ONE + ONE / 2, y], [ONE, 0], ONE), + ], + &[(0, 1)], + ); + let parted = hand_world( + 2, + 2, + &[ + hand_body(0, Dynamic, [9 * ONE + ONE / 2, y], [-ONE, 0], ONE), + hand_body(1, Dynamic, [13 * ONE + ONE / 2, y], [ONE, 0], ONE), + ], + &[], + ); + check_hand_scene(&start, &[touching, parted]); +} + +/// A resting unit circle and a resting half-unit projectile one unit apart +/// overlap by half a unit; each moves a quarter unit away and, at rest, no +/// impulse applies. The projectile starts with two frames to live: it ages +/// to one and stays, in contact. Next frame it ages to zero and despawns +/// before integration, taking its contact edge with it; its number is not +/// reused. +#[test] +fn f6_hand_projectile_despawns_and_drops_its_contact() { + use game::{ + Kind::{Dynamic, Projectile}, + ONE, + }; + let y = 30 * ONE; + let projectile = |pos, ttl| game::Body { + ttl, + ..hand_body(1, Projectile, pos, [0, 0], ONE / 2) + }; + let start = hand_world( + 0, + 2, + &[ + hand_body(0, Dynamic, [30 * ONE, y], [0, 0], ONE), + projectile([31 * ONE, y], 2), + ], + &[], + ); + let resting = hand_body(0, Dynamic, [30 * ONE - ONE / 4, y], [0, 0], ONE); + let aged = hand_world( + 1, + 2, + &[resting, projectile([31 * ONE + ONE / 4, y], 1)], + &[(0, 1)], + ); + let despawned = hand_world(2, 2, &[resting], &[]); + check_hand_scene(&start, &[aged, despawned]); +} From a4df6b2a02282192dac763bb8f23b710c8c2254d Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 21 Oct 2026 05:03:24 -0700 Subject: [PATCH 4/4] Document F6 in the benchmark scene fixtures topic The topic listed C1 and F5 as the implemented fixtures. It now lists F6 and its runner, and says what a valid F6 run shows and does not show, and what its headline and residual lines measure. Co-Authored-By: Claude Opus 5.5 --- docs/topics/BenchmarkSceneFixtures.md | 32 ++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/docs/topics/BenchmarkSceneFixtures.md b/docs/topics/BenchmarkSceneFixtures.md index 8b29cd72..54023bae 100644 --- a/docs/topics/BenchmarkSceneFixtures.md +++ b/docs/topics/BenchmarkSceneFixtures.md @@ -279,7 +279,8 @@ to 93–119 ms while the load average looked ordinary. ## Implemented fixtures -The C1 star control and F5 append-and-replay workload are implemented. +The C1 star control, the F5 append-and-replay workload and the F6 game +scenes are implemented. Their conformance is checked by `crates/warp-core/tests/scene_fixture_tests.rs`, which CI runs with `native_rule_bootstrap,trusted_runtime`. @@ -292,6 +293,8 @@ Their conformance is checked by | C1 | `benches/support/fixtures/star.rs` | The star scene built independently of `action_scene`, with identical state roots at 1, 100 and 1,000 cells, and a pinned root | | F5 | `benches/support/fixtures/think_append.rs` | Append and replay through the trusted host, validated | | Runner | `benches/scene_fixtures.rs` | A fixed matrix (tiers S and M, seed 7, five rounds each) of classified rounds; a summary only when every round is valid | +| F6 | `benches/support/fixtures/game.rs` | Arena, pile and bullets game scenes run frame by frame through the trusted host, validated against an in-memory reference | +| F6 runner | `benches/game_scenes.rs` | Each scene once per commitment profile, seed 6; measured frame latencies and phases only when the run is valid | ### F5 append and replay @@ -330,6 +333,33 @@ Their conformance is checked by Validation, outcome collection and checkpoint readings are outside both. The runner is a normal build; it does not measure memory yet. +### F6 game scenes + +- **Construction.** Each scene starts a trusted host, installs the game's + contract package and builds its world. Every frame submits one step intent + carrying that frame's input, admits it, ticks, and reads every body back as + a renderer would. Bodies are fixed-point (Q16.16) circles; a body is a node + with its state as an attachment and an edge from its grid cell, and each + touching pair has a contact edge. +- **One rewrite per frame.** The step is one rule application that reads the + whole world and writes what changed. Per-cell rewrites are not built: + neighbouring cells overlap, and Echo rejects a conflicting rewrite. +- **What validity shows, and what it does not.** The same pure `step` + function drives the Echo rule and the in-memory reference. A valid run shows + that hosted execution stored, and the graph gave back, exactly what the + reference path computed. It does not show that `step` is correct physics: a + bug in `step` occurs on both sides. Two small hand-computed scenes in the + conformance test check collision, despawn and contact edges independently. +- **Footprint enforcement.** The debug-build conformance test runs scaled-down + scenes, so footprint enforcement checks every read and write the step makes. +- **Clocks.** The headline line reports only measured durations: frame + latency percentiles and maximum, frames over the 60 Hz budget, start-up, the + render read, the reference step timed alone outside Echo, teardown and + replay. A separate residual line reports frame time minus twice the + reference step. It is an estimate, not Echo's cost and not a bound on it: + cache effects, measurement conditions and the rule's own read-and-diff work + prevent that inference. + Gates: the **correctness gate** is the conformance test above. The **performance gate** is not enabled; its environment, comparison protocol and tolerance are not yet established.