use rustsim::prelude::*;
#[derive(Debug, Clone)]
struct Particle {
id: AgentId,
x: f32,
vx: f32,
}
impl Agent for Particle {
fn id(&self) -> AgentId {
self.id
}
}
impl SoaExtractable for Particle {
fn num_columns() -> usize {
2
}
fn column_names() -> Vec<&'static str> {
vec!["x", "vx"]
}
fn extract_row(&self, columns: &mut [Vec<f32>]) {
columns[0].push(self.x);
columns[1].push(self.vx);
}
fn write_back_row(&mut self, columns: &[&[f32]], row: usize) {
self.x = columns[0][row];
self.vx = columns[1][row];
}
}
#[derive(Debug, Clone)]
struct Particle64 {
id: AgentId,
x: f64,
vx: f64,
}
#[derive(Debug, Clone)]
struct SpatialParticle {
id: AgentId,
x: f32,
y: f32,
neighbors: f32,
}
impl Agent for SpatialParticle {
fn id(&self) -> AgentId {
self.id
}
}
impl SoaExtractable for SpatialParticle {
fn num_columns() -> usize {
3
}
fn column_names() -> Vec<&'static str> {
vec!["x", "y", "neighbors"]
}
fn extract_row(&self, columns: &mut [Vec<f32>]) {
columns[0].push(self.x);
columns[1].push(self.y);
columns[2].push(self.neighbors);
}
fn write_back_row(&mut self, columns: &[&[f32]], row: usize) {
self.x = columns[0][row];
self.y = columns[1][row];
self.neighbors = columns[2][row];
}
}
#[derive(Debug, Clone)]
struct SpatialParticle64 {
id: AgentId,
x: f64,
y: f64,
neighbors: f64,
}
impl Agent for SpatialParticle64 {
fn id(&self) -> AgentId {
self.id
}
}
impl SoaExtractableF64 for SpatialParticle64 {
fn num_columns() -> usize {
3
}
fn column_names() -> Vec<&'static str> {
vec!["x", "y", "neighbors"]
}
fn extract_row(&self, columns: &mut [Vec<f64>]) {
columns[0].push(self.x);
columns[1].push(self.y);
columns[2].push(self.neighbors);
}
fn write_back_row(&mut self, columns: &[&[f64]], row: usize) {
self.x = columns[0][row];
self.y = columns[1][row];
self.neighbors = columns[2][row];
}
}
impl Agent for Particle64 {
fn id(&self) -> AgentId {
self.id
}
}
impl SoaExtractableF64 for Particle64 {
fn num_columns() -> usize {
2
}
fn column_names() -> Vec<&'static str> {
vec!["x", "vx"]
}
fn extract_row(&self, columns: &mut [Vec<f64>]) {
columns[0].push(self.x);
columns[1].push(self.vx);
}
fn write_back_row(&mut self, columns: &[&[f64]], row: usize) {
self.x = columns[0][row];
self.vx = columns[1][row];
}
}
fn particle_store() -> HashMapStore<Particle> {
let mut store = HashMapStore::new();
store.insert(Particle {
id: 1,
x: 0.0,
vx: 1.0,
});
store.insert(Particle {
id: 2,
x: 10.0,
vx: -2.0,
});
store
}
fn particle64_store() -> HashMapStore<Particle64> {
let mut store = HashMapStore::new();
store.insert(Particle64 {
id: 1,
x: 1.0 + 1.0e-10,
vx: 1.0e-10,
});
store.insert(Particle64 {
id: 2,
x: 10.0,
vx: -0.25,
});
store
}
fn spatial_particle_store() -> VecStore<SpatialParticle> {
let mut store = VecStore::new();
store.insert(SpatialParticle {
id: 1,
x: 0.0,
y: 0.0,
neighbors: 0.0,
});
store.insert(SpatialParticle {
id: 2,
x: 0.8,
y: 0.0,
neighbors: 0.0,
});
store.insert(SpatialParticle {
id: 3,
x: 4.0,
y: 0.0,
neighbors: 0.0,
});
store
}
fn spatial_particle64_store() -> VecStore<SpatialParticle64> {
let mut store = VecStore::new();
store.insert(SpatialParticle64 {
id: 1,
x: 0.0,
y: 0.0,
neighbors: 0.0,
});
store.insert(SpatialParticle64 {
id: 2,
x: 0.0,
y: 0.9,
neighbors: 0.0,
});
store
}
#[test]
fn columnar_runtime_keeps_soa_authoritative_until_download() {
let store = particle_store();
let mut runtime = ColumnarRuntime::upload::<Particle, _>(&store);
runtime.add_cpu_phase("integrate", |columns, n| {
let (x_cols, rest) = columns.split_at_mut(1);
let x = &mut x_cols[0];
let vx = &rest[0];
for row in 0..n {
x[row] += vx[row];
}
});
runtime.add_cpu_phase("accelerate", |columns, n| {
for v in columns[1].iter_mut().take(n) {
*v *= 2.0;
}
});
let timing = runtime.step().unwrap();
assert_eq!(timing.step_index, 0);
assert_eq!(timing.agent_count, 2);
assert_eq!(timing.phases.len(), 2);
assert_eq!(runtime.step_index(), 1);
assert_eq!(store.get(1).unwrap().x, 0.0);
assert_eq!(store.get(2).unwrap().x, 10.0);
runtime.download::<Particle, _>(&store).unwrap();
let a = store.get(1).unwrap();
let b = store.get(2).unwrap();
assert_eq!(a.x, 1.0);
assert_eq!(a.vx, 2.0);
assert_eq!(b.x, 8.0);
assert_eq!(b.vx, -4.0);
}
#[test]
fn columnar_runtime_scatter_mutates_authoritative_state() {
let store = particle_store();
let mut runtime = ColumnarRuntime::upload::<Particle, _>(&store);
runtime.scatter_remove(&[1]).unwrap();
runtime
.scatter_insert(&[3], &[&[20.0_f32][..], &[0.5_f32][..]])
.unwrap();
runtime.add_cpu_phase("integrate", |columns, n| {
let (x_cols, rest) = columns.split_at_mut(1);
let x = &mut x_cols[0];
let vx = &rest[0];
for row in 0..n {
x[row] += vx[row];
}
});
runtime.step().unwrap();
assert_eq!(runtime.agent_count(), 2);
assert_eq!(runtime.ids(), &[2, 3]);
assert_eq!(runtime.device_store().column(0), &[8.0, 20.5]);
}
#[test]
fn columnar_runtime_scatter_insert_reports_typed_shape_errors() {
let store = particle_store();
let mut runtime = ColumnarRuntime::upload::<Particle, _>(&store);
let err = runtime.scatter_insert(&[3], &[&[1.0_f32][..]]).unwrap_err();
assert!(matches!(
err,
ColumnarRuntimeError::Mutation(DeviceSoaMutationError::ColumnCountMismatch {
expected: 2,
actual: 1,
})
));
let err = runtime
.scatter_insert(&[1], &[&[1.0_f32][..], &[2.0_f32][..]])
.unwrap_err();
assert!(matches!(
err,
ColumnarRuntimeError::Mutation(DeviceSoaMutationError::ExistingAgentId(1))
));
}
#[test]
fn columnar_runtime_lifecycle_commands_are_authoritative_and_reported() {
let store = particle_store();
let mut runtime = ColumnarRuntime::upload::<Particle, _>(&store);
assert!(runtime.contains_id(1));
assert_eq!(runtime.row_of(1), Some(0));
assert_eq!(runtime.next_available_id(), 0);
let report = runtime
.apply_lifecycle(ColumnarLifecycleCommand::Replace {
remove_ids: vec![1],
insert: ColumnarAgentBatch::new(vec![0], vec![vec![50.0], vec![5.0]]),
})
.unwrap();
assert_eq!(report.removed, 1);
assert_eq!(report.inserted, 1);
assert_eq!(report.agent_count, 2);
assert!(!runtime.contains_id(1));
assert!(runtime.contains_id(0));
assert_eq!(runtime.ids(), &[2, 0]);
runtime.add_cpu_phase("integrate", |columns, n| {
let (x_cols, rest) = columns.split_at_mut(1);
let x = &mut x_cols[0];
let vx = &rest[0];
for row in 0..n {
x[row] += vx[row];
}
});
runtime.step().unwrap();
assert_eq!(runtime.device_store().column(0), &[8.0, 55.0]);
assert_eq!(store.get(1).unwrap().x, 0.0);
}
#[test]
fn columnar_runtime_lifecycle_replace_is_validated_before_mutation() {
let store = particle_store();
let mut runtime = ColumnarRuntime::upload::<Particle, _>(&store);
let before_ids = runtime.ids().to_vec();
let before_x = runtime.device_store().column(0).to_vec();
let err = runtime
.apply_lifecycle(ColumnarLifecycleCommand::Replace {
remove_ids: vec![1],
insert: ColumnarAgentBatch::new(vec![2], vec![vec![99.0], vec![1.0]]),
})
.unwrap_err();
assert!(matches!(
err,
ColumnarRuntimeError::Mutation(DeviceSoaMutationError::ExistingAgentId(2))
));
assert_eq!(runtime.ids(), before_ids.as_slice());
assert_eq!(runtime.device_store().column(0), before_x.as_slice());
}
#[test]
fn columnar_runtime_lifecycle_remove_reports_missing_ids() {
let store = particle_store();
let mut runtime = ColumnarRuntime::upload::<Particle, _>(&store);
let err = runtime
.apply_lifecycle(ColumnarLifecycleCommand::Remove(vec![999]))
.unwrap_err();
assert!(matches!(
err,
ColumnarRuntimeError::Mutation(DeviceSoaMutationError::MissingAgentId(999))
));
assert_eq!(runtime.agent_count(), 2);
}
#[test]
fn columnar_runtime_f64_keeps_double_precision_authoritative() {
let store = particle64_store();
let mut runtime = ColumnarRuntimeF64::upload::<Particle64, _>(&store);
assert_eq!(
runtime.device_store().schema()[0].column_type,
SoaColumnType::F64
);
runtime.add_cpu_phase("integrate", |columns, n| {
let (x_cols, rest) = columns.split_at_mut(1);
let x = &mut x_cols[0];
let vx = &rest[0];
for row in 0..n {
x[row] += vx[row];
}
});
runtime.step().unwrap();
assert_eq!(store.get(1).unwrap().x, 1.0 + 1.0e-10);
let row_1 = runtime
.ids()
.iter()
.position(|id| *id == 1)
.expect("agent 1 should remain in the runtime");
assert_eq!(runtime.device_store().column(0)[row_1], 1.0 + 2.0e-10);
let checkpoint = runtime.device_store().checkpoint();
assert_eq!(checkpoint.schema[0].column_type, SoaColumnType::F64);
assert_eq!(checkpoint.columns[0][row_1], 1.0 + 2.0e-10);
runtime.download::<Particle64, _>(&store).unwrap();
assert_eq!(store.get(1).unwrap().x, 1.0 + 2.0e-10);
}
#[test]
fn columnar_runtime_f64_lifecycle_matches_f32_contract() {
let store = particle64_store();
let mut runtime = ColumnarRuntimeF64::upload::<Particle64, _>(&store);
let report = runtime
.apply_lifecycle(ColumnarLifecycleCommandF64::Replace {
remove_ids: vec![1],
insert: ColumnarAgentBatchF64::new(vec![3], vec![vec![7.5], vec![0.25]]),
})
.unwrap();
assert_eq!(report.removed, 1);
assert_eq!(report.inserted, 1);
assert_eq!(report.agent_count, 2);
assert!(!runtime.contains_id(1));
assert_eq!(runtime.ids(), &[2, 3]);
runtime.add_cpu_phase("integrate", |columns, n| {
let (x_cols, rest) = columns.split_at_mut(1);
let x = &mut x_cols[0];
let vx = &rest[0];
for row in 0..n {
x[row] += vx[row];
}
});
runtime.step().unwrap();
assert_eq!(runtime.device_store().column(0), &[9.75, 7.75]);
}
#[test]
fn columnar_runtime_spatial_index_queries_authoritative_soa_rows() {
let store = spatial_particle_store();
let mut runtime = ColumnarRuntime::upload::<SpatialParticle, _>(&store);
let index = runtime
.spatial_index_2d(DeviceSpatialConfig2D::new(0, 1, 1.0))
.unwrap();
assert_eq!(index.agent_count(), 3);
assert_eq!(index.ids(), &[1, 2, 3]);
assert!(!index.cells().is_empty());
assert_eq!(index.sorted_rows().len(), 3);
let neighbors = index.neighbors_for_id(1).unwrap();
assert_eq!(neighbors.len(), 1);
assert_eq!(neighbors[0].target_id, 2);
assert_eq!(neighbors[0].source_row, 0);
assert_eq!(neighbors[0].target_row, 1);
assert!(index.neighbors_for_id(999).is_err());
}
#[test]
fn columnar_runtime_spatial_phase_mutates_authoritative_soa_state() {
let store = spatial_particle_store();
let mut runtime = ColumnarRuntime::upload::<SpatialParticle, _>(&store);
runtime
.add_spatial_cpu_phase_2d(
"count_neighbors",
DeviceSpatialConfig2D::new(0, 1, 1.0),
|index, columns, n| {
for row in 0..n {
columns[2][row] = index.neighbors_for_row(row).unwrap().len() as f32;
}
},
)
.unwrap();
let timing = runtime.step().unwrap();
assert_eq!(timing.agent_count, 3);
assert_eq!(timing.phases.len(), 1);
assert_eq!(runtime.device_store().column(2), &[1.0, 1.0, 0.0]);
assert_eq!(store.get(1).unwrap().neighbors, 0.0);
runtime.download::<SpatialParticle, _>(&store).unwrap();
assert_eq!(store.get(1).unwrap().neighbors, 1.0);
}
#[test]
fn columnar_runtime_spatial_index_rebuilds_after_lifecycle_mutation() {
let store = spatial_particle_store();
let mut runtime = ColumnarRuntime::upload::<SpatialParticle, _>(&store);
let config = DeviceSpatialConfig2D::new(0, 1, 1.0);
runtime
.apply_lifecycle(ColumnarLifecycleCommand::Replace {
remove_ids: vec![3],
insert: ColumnarAgentBatch::new(vec![4], vec![vec![0.4], vec![0.0], vec![0.0]]),
})
.unwrap();
let index = runtime.spatial_index_2d(config).unwrap();
let mut neighbor_ids: Vec<_> = index
.neighbors_for_id(1)
.unwrap()
.into_iter()
.map(|neighbor| neighbor.target_id)
.collect();
neighbor_ids.sort_unstable();
assert_eq!(runtime.ids(), &[1, 2, 4]);
assert_eq!(neighbor_ids, vec![2, 4]);
}
#[test]
fn columnar_runtime_spatial_phase_reports_typed_config_errors() {
let store = spatial_particle_store();
let mut runtime = ColumnarRuntime::upload::<SpatialParticle, _>(&store);
let err = runtime
.add_spatial_cpu_phase_2d(
"bad_column",
DeviceSpatialConfig2D::new(0, 99, 1.0),
|_index, _columns, _n| {},
)
.unwrap_err();
assert!(matches!(
err,
ColumnarRuntimeError::Spatial(DeviceSpatialError::MissingColumn {
column: 99,
num_columns: 3,
})
));
let err = runtime
.spatial_index_2d(DeviceSpatialConfig2D::new(0, 1, 0.0))
.unwrap_err();
assert!(matches!(
err,
ColumnarRuntimeError::Spatial(DeviceSpatialError::InvalidRadius(0.0))
));
}
#[test]
fn columnar_runtime_f64_spatial_phase_matches_f32_contract() {
let store = spatial_particle64_store();
let mut runtime = ColumnarRuntimeF64::upload::<SpatialParticle64, _>(&store);
runtime
.add_spatial_cpu_phase_2d(
"count_neighbors_f64",
DeviceSpatialConfig2D::new(0, 1, 1.0),
|index, columns, n| {
for row in 0..n {
columns[2][row] = index.neighbors_for_row(row).unwrap().len() as f64;
}
},
)
.unwrap();
runtime.step().unwrap();
assert_eq!(runtime.device_store().column(2), &[1.0, 1.0]);
}
#[cfg(feature = "rayon")]
#[test]
fn columnar_runtime_runs_rayon_phase_when_enabled() {
let store = particle_store();
let mut runtime = ColumnarRuntime::upload::<Particle, _>(&store);
runtime
.add_rayon_phase("parallel_integrate", 1, |start, columns| {
let (x_cols, rest) = columns.split_at_mut(1);
let x = &mut x_cols[0];
let vx = &rest[0];
for row in 0..x.len() {
x[row] += vx[row] + start as f32 * 0.0;
}
})
.unwrap();
let timing = runtime.step().unwrap();
assert_eq!(timing.phases[0].backend, ColumnarPhaseBackend::Rayon);
assert_eq!(runtime.device_store().column(0).len(), 2);
}