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..232cd629 --- /dev/null +++ b/crates/warp-core/benches/game_scenes.rs @@ -0,0 +1,127 @@ +// 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 +//! 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; + +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 +} + +/// 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) * per_mille + 500) / 1000; + 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(); + 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} \ + reference_step_p50_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, 500), + percentile(&t.frames, 900), + percentile(&t.frames, 990), + percentile(&t.frames, 1000), + percentile(&t.render, 500), + percentile(&t.physics, 500), + 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() { + 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..f5b39fe4 --- /dev/null +++ b/crates/warp-core/benches/support/fixtures/game.rs @@ -0,0 +1,1389 @@ +// 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. 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 +//! 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, 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; + 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, 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; + 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: [`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], + /// 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, timed outside Echo, before the + /// hosted run. + 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, 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`. +#[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, initial_world(scene, seed), move |state| { + state.with_commitment_profile(profile) + }) +} + +fn run_scene( + scene: Scene, + seed: u64, + start_world: World, + profile: impl FnOnce(WorldlineState) -> WorldlineState, +) -> Observation { + let mut obs = Observation::default(); + 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..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)] @@ -16,7 +18,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 +247,186 @@ 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" + ); +} + +/// 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]); +} 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.