//! Gaussian Splat Field Geometry — Block K //! //! Regions in the thermal field are not points — they are overlapping //! Gaussian influence zones. Each splat has a position (size-class //! centroid), opacity (temperature), and covariance (how far its //! influence radiates). Splats adaptively split when internally diverse //! and merge when redundantly similar. A tiled scan prioritises hot //! regions so the field evolves efficiently at scale. use std::collections::HashMap; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- /// A single Gaussian splat — one managed memory region. #[derive(Clone, Debug)] pub struct Splat { pub id: u32, /// Size-class centroid (log-space address / size class index). pub position: f64, /// Temperature / opacity: 0.0 (cold) → 1.0 (hot). pub opacity: f64, /// Correlation spread — how far this splat's influence reaches. pub covariance: f64, /// Total bytes managed by this splat. pub mass: usize, pub process_id: u32, pub access_count: u64, /// Child splat IDs when this splat has been split. pub child_ids: Vec, /// Parent splat ID when this splat was produced by a merge. pub parent_id: Option, } /// A tile — a contiguous position-range bucket of splats scanned together. #[derive(Clone, Debug)] pub struct Tile { pub id: u32, pub splat_ids: Vec, /// Average opacity of member splats. pub heat: f64, /// Hot tiles are scanned more often than cold ones. pub scan_priority: f64, pub last_scan_ns: u64, } /// The field: a collection of splats partitioned into tiles. pub struct SplatField { splats: HashMap, tiles: Vec, next_splat_id: u32, tile_scan_cursor: usize, /// Coefficient-of-variation threshold above which a splat is split. split_threshold: f64, /// Similarity threshold above which two splats are merged. merge_threshold: f64, /// Maximum total (opacity × mass) in bytes. ram_budget_bytes: usize, } /// Per-cycle summary produced by [`SplatField::summary`]. #[derive(Clone, Debug)] pub struct SplatSummary { pub total_splats: usize, pub splits_this_cycle: usize, pub merges_this_cycle: usize, pub tiles_scanned: usize, pub total_opacity: f64, pub hottest_splat: Option<(u32, f64)>, pub coldest_splat: Option<(u32, f64)>, } // --------------------------------------------------------------------------- // SplatField implementation // --------------------------------------------------------------------------- impl SplatField { // ----------------------------------------------------------------------- // Construction // ----------------------------------------------------------------------- /// Create a new `SplatField`. /// /// * `ram_budget_bytes` — maximum total weighted energy (opacity × mass). /// * `split_threshold` — coefficient of variation above which a splat splits. /// * `merge_threshold` — similarity above which two splats merge. pub fn new( ram_budget_bytes: usize, split_threshold: f64, merge_threshold: f64, ) -> Self { Self { splats: HashMap::new(), tiles: Vec::new(), next_splat_id: 0, tile_scan_cursor: 0, split_threshold, merge_threshold, ram_budget_bytes, } } // ----------------------------------------------------------------------- // Splat lifecycle // ----------------------------------------------------------------------- /// Add a splat to the field and return its assigned ID. pub fn add_splat( &mut self, position: f64, opacity: f64, covariance: f64, mass: usize, process_id: u32, ) -> u32 { let id = self.next_splat_id; self.next_splat_id += 1; self.splats.insert( id, Splat { id, position, opacity: opacity.clamp(0.0, 1.0), covariance, mass, process_id, access_count: 0, child_ids: Vec::new(), parent_id: None, }, ); id } /// Remove a splat from the field. pub fn remove_splat(&mut self, id: u32) { self.splats.remove(&id); // Purge the id from any tile that still references it. for tile in self.tiles.iter_mut() { tile.splat_ids.retain(|&s| s != id); } } // ----------------------------------------------------------------------- // Access // ----------------------------------------------------------------------- /// Mark a splat as accessed: push opacity toward 1.0 and increment counter. pub fn access(&mut self, id: u32) { if let Some(splat) = self.splats.get_mut(&id) { // Heat injection: strong enough to overcome per-step decay. let heat = 0.5 * (1.0 - splat.opacity) + 0.1; splat.opacity = (splat.opacity + heat).min(1.0); splat.access_count += 1; } } // ----------------------------------------------------------------------- // Gaussian influence // ----------------------------------------------------------------------- /// Compute the Gaussian influence the source splat exerts on the target. /// /// `influence = opacity_source × exp(-0.5 × ((Δpos / covariance_source)²))` /// /// Returns 0.0 if either splat does not exist or if covariance is zero. pub fn compute_influence(&self, source_id: u32, target_id: u32) -> f64 { let source = match self.splats.get(&source_id) { Some(s) => s, None => return 0.0, }; let target = match self.splats.get(&target_id) { Some(t) => t, None => return 0.0, }; if source.covariance == 0.0 { return 0.0; } let delta = (source.position - target.position) / source.covariance; source.opacity * (-0.5 * delta * delta).exp() } // ----------------------------------------------------------------------- // Field evolution // ----------------------------------------------------------------------- /// Advance the field by one step. /// /// 1. For each splat, accumulate Gaussian-weighted influence from every /// other splat (activation = weighted sum). /// 2. Apply the Lenia-style Gaussian growth function to that activation. /// 3. Apply natural decay (opacity × 0.98). /// 4. Enforce mass conservation: if total (opacity × mass) exceeds the RAM /// budget, scale all opacities down proportionally. pub fn step(&mut self, _dt: f64) { // Collect all current splat IDs to avoid borrow issues. let ids: Vec = self.splats.keys().copied().collect(); // Phase 1: compute new opacities. let mut new_opacities: HashMap = HashMap::new(); for &id in &ids { let old_opacity = match self.splats.get(&id) { Some(s) => s.opacity, None => continue, }; // Accumulate influence from all other splats. let mut activation = 0.0f64; for &other_id in &ids { if other_id == id { continue; } activation += self.compute_influence(other_id, id); } // Growth function: Gaussian bump centred at 0.5, sigma = 0.15. // Returns a value in [0, 1]. We treat it as a growth delta. let growth = growth_fn(activation); // New opacity: apply growth bump then decay. let new_opacity = ((old_opacity + growth * 0.1) * 0.98).clamp(0.0, 1.0); new_opacities.insert(id, new_opacity); } // Phase 2: write back new opacities. for (&id, &new_op) in &new_opacities { if let Some(splat) = self.splats.get_mut(&id) { splat.opacity = new_op; } } // Phase 3: mass conservation. let total_energy: f64 = self .splats .values() .map(|s| s.opacity * s.mass as f64) .sum(); if total_energy > self.ram_budget_bytes as f64 && total_energy > 0.0 { let scale = self.ram_budget_bytes as f64 / total_energy; for splat in self.splats.values_mut() { splat.opacity = (splat.opacity * scale).clamp(0.0, 1.0); } } } // ----------------------------------------------------------------------- // Adaptive split / merge // ----------------------------------------------------------------------- /// Attempt to split a splat into children. /// /// `sub_opacities` is a slice of per-sub-region opacity samples inside the /// splat. If the coefficient of variation of those samples exceeds /// `split_threshold`, the splat is split into `sub_opacities.len()` /// children and their IDs are returned. The parent's `child_ids` are /// updated; each child's `parent_id` is set to `None` (they are new roots). /// Returns `None` if the splat does not exist, has fewer than two /// sub-opacities, or the internal diversity is below the threshold. pub fn try_split(&mut self, id: u32, sub_opacities: &[f64]) -> Option> { if sub_opacities.len() < 2 { return None; } // Read parent data first (immutable borrow). let (parent_pos, parent_cov, parent_mass, parent_pid) = { let parent = self.splats.get(&id)?; ( parent.position, parent.covariance, parent.mass, parent.process_id, ) }; // Compute coefficient of variation. let n = sub_opacities.len() as f64; let mean: f64 = sub_opacities.iter().sum::() / n; if mean == 0.0 { return None; } let variance: f64 = sub_opacities.iter().map(|&x| (x - mean).powi(2)).sum::() / n; let cv = variance.sqrt() / mean; if cv <= self.split_threshold { return None; } // Create one child per sub-region, spread evenly around parent position. let spread = parent_cov; let n_children = sub_opacities.len(); let child_mass = parent_mass / n_children.max(1); let child_cov = parent_cov / 2.0; let mut child_ids = Vec::with_capacity(n_children); for (i, &sub_op) in sub_opacities.iter().enumerate() { // Spread children symmetrically around parent position. let offset = (i as f64 - (n_children as f64 - 1.0) / 2.0) * spread / n_children as f64; let child_id = self.next_splat_id; self.next_splat_id += 1; self.splats.insert( child_id, Splat { id: child_id, position: parent_pos + offset, opacity: sub_op.clamp(0.0, 1.0), covariance: child_cov, mass: child_mass, process_id: parent_pid, access_count: 0, child_ids: Vec::new(), parent_id: Some(id), }, ); child_ids.push(child_id); } // Update parent's child list. if let Some(parent) = self.splats.get_mut(&id) { parent.child_ids = child_ids.clone(); } Some(child_ids) } /// Attempt to merge a set of splats into one. /// /// Merges if every pair in `ids` has opacity within 10% of each other /// AND the Gaussian influence between all pairs exceeds `merge_threshold`. /// Returns the ID of the new merged splat, or `None` if the conditions are /// not met or fewer than two IDs are provided. pub fn try_merge(&mut self, ids: &[u32]) -> Option { if ids.len() < 2 { return None; } // Gather splat snapshots. let splats: Vec = ids .iter() .filter_map(|&id| self.splats.get(&id).cloned()) .collect(); if splats.len() < 2 { return None; } // Check temperature similarity: all opacities within 10% of the mean. let mean_opacity: f64 = splats.iter().map(|s| s.opacity).sum::() / splats.len() as f64; let all_similar = splats .iter() .all(|s| (s.opacity - mean_opacity).abs() <= 0.1); if !all_similar { return None; } // Check pairwise Gaussian correlation (use compute_influence proxy): // influence between two splats must exceed merge_threshold. for i in 0..splats.len() { for j in (i + 1)..splats.len() { let influence = self.compute_influence(splats[i].id, splats[j].id); if influence < self.merge_threshold { return None; } } } // Build the merged splat. let merged_position = splats.iter().map(|s| s.position).sum::() / splats.len() as f64; let merged_opacity = mean_opacity; let merged_covariance = splats.iter().map(|s| s.covariance).sum::() / splats.len() as f64; let merged_mass: usize = splats.iter().map(|s| s.mass).sum(); let merged_pid = splats[0].process_id; let merged_access: u64 = splats.iter().map(|s| s.access_count).sum(); let merged_id = self.next_splat_id; self.next_splat_id += 1; self.splats.insert( merged_id, Splat { id: merged_id, position: merged_position, opacity: merged_opacity.clamp(0.0, 1.0), covariance: merged_covariance, mass: merged_mass, process_id: merged_pid, access_count: merged_access, child_ids: Vec::new(), parent_id: None, }, ); // Remove the source splats. for id in ids { self.remove_splat(*id); } Some(merged_id) } // ----------------------------------------------------------------------- // Tiled scanning // ----------------------------------------------------------------------- /// Partition all current splats into `num_tiles` tiles by position range. /// /// Tiles are rebuilt from scratch each call. After partitioning, each /// tile's `heat` and `scan_priority` are recomputed. pub fn partition_tiles(&mut self, num_tiles: usize) { if num_tiles == 0 || self.splats.is_empty() { self.tiles.clear(); return; } // Find position range. let min_pos = self .splats .values() .map(|s| s.position) .fold(f64::INFINITY, f64::min); let max_pos = self .splats .values() .map(|s| s.position) .fold(f64::NEG_INFINITY, f64::max); let range = (max_pos - min_pos).max(1e-12); let tile_width = range / num_tiles as f64; // Build tiles. let mut tiles: Vec = (0..num_tiles) .map(|i| Tile { id: i as u32, splat_ids: Vec::new(), heat: 0.0, scan_priority: 0.0, last_scan_ns: 0, }) .collect(); for splat in self.splats.values() { let idx = ((splat.position - min_pos) / tile_width) as usize; let idx = idx.min(num_tiles - 1); tiles[idx].splat_ids.push(splat.id); } // Compute per-tile heat and scan priority. for tile in tiles.iter_mut() { if tile.splat_ids.is_empty() { tile.heat = 0.0; tile.scan_priority = 0.0; continue; } let total_opacity: f64 = tile .splat_ids .iter() .filter_map(|&id| self.splats.get(&id)) .map(|s| s.opacity) .sum(); tile.heat = total_opacity / tile.splat_ids.len() as f64; tile.scan_priority = tile.heat; // hot tiles scan more } self.tiles = tiles; // Reset cursor so iteration starts from a fresh position. self.tile_scan_cursor = 0; } /// Advance the round-robin tile cursor and return the next tile to scan. /// /// The cursor is biased toward hot tiles: after returning a tile it bumps /// `scan_priority` by 1.0 for hot tiles so they rise to the top of /// future natural ordering, but the cursor itself is a simple modular /// advance for predictability. `last_scan_ns` is updated on the returned /// tile. /// /// Returns `None` if there are no tiles. pub fn scan_next_tile(&mut self, now_ns: u64) -> Option<&Tile> { if self.tiles.is_empty() { return None; } // Find the tile with the highest scan_priority, using the cursor as a // tiebreaker (prefer tiles that haven't been scanned recently in order). // This gives hot tiles more frequent visits while still cycling through all. let n = self.tiles.len(); // Pick the tile with maximum scan_priority; ties broken by cursor order. let mut best_idx = self.tile_scan_cursor % n; let mut best_priority = self.tiles[best_idx].scan_priority; for i in 1..n { let idx = (self.tile_scan_cursor + i) % n; if self.tiles[idx].scan_priority > best_priority { best_priority = self.tiles[idx].scan_priority; best_idx = idx; } } // Update the chosen tile. self.tiles[best_idx].last_scan_ns = now_ns; // Reduce its scan_priority so it won't monopolise — decay toward heat baseline. self.tiles[best_idx].scan_priority = self.tiles[best_idx].heat; // reset; will grow again next partition // Advance cursor. self.tile_scan_cursor = (best_idx + 1) % n; Some(&self.tiles[best_idx]) } // ----------------------------------------------------------------------- // Queries // ----------------------------------------------------------------------- /// Return IDs of all splats whose opacity is below `threshold`. pub fn get_cold_splats(&self, threshold: f64) -> Vec { self.splats .values() .filter(|s| s.opacity < threshold) .map(|s| s.id) .collect() } /// Return IDs of all splats whose opacity is above `threshold`. pub fn get_hot_splats(&self, threshold: f64) -> Vec { self.splats .values() .filter(|s| s.opacity > threshold) .map(|s| s.id) .collect() } /// Summarise the current field state. pub fn summary(&self) -> SplatSummary { let total_opacity: f64 = self.splats.values().map(|s| s.opacity).sum(); let hottest = self .splats .values() .max_by(|a, b| a.opacity.partial_cmp(&b.opacity).unwrap()) .map(|s| (s.id, s.opacity)); let coldest = self .splats .values() .min_by(|a, b| a.opacity.partial_cmp(&b.opacity).unwrap()) .map(|s| (s.id, s.opacity)); SplatSummary { total_splats: self.splats.len(), splits_this_cycle: 0, // caller tracks across calls merges_this_cycle: 0, tiles_scanned: 0, total_opacity, hottest_splat: hottest, coldest_splat: coldest, } } } // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- /// Lenia-style Gaussian growth function. /// /// Returns a value in [0, 1]: peaks when `activation` ≈ 0.5, falls toward 0 /// for very low or very high activation. #[inline] fn growth_fn(activation: f64) -> f64 { let x = (activation - 0.5) / 0.15; (-0.5 * x * x).exp() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn make_field() -> SplatField { SplatField::new( 1_000_000_000, // 1 GB budget — generous for tests 0.3, // split_threshold: CV > 0.3 → split 0.05, // merge_threshold: influence > 0.05 → eligible for merge ) } // ----------------------------------------------------------------------- #[test] fn test_gaussian_influence_falloff() { let mut field = make_field(); // Source at position 0.0, covariance 1.0, full opacity. let src = field.add_splat(0.0, 1.0, 1.0, 1024, 1); // Near target: position 0.5 let near = field.add_splat(0.5, 0.5, 1.0, 1024, 1); // Far target: position 5.0 let far = field.add_splat(5.0, 0.5, 1.0, 1024, 1); let near_inf = field.compute_influence(src, near); let far_inf = field.compute_influence(src, far); assert!( near_inf > far_inf, "Closer target must receive more influence: near={near_inf:.4} far={far_inf:.4}" ); assert!(near_inf > 0.0, "Near influence must be positive"); assert!(far_inf >= 0.0, "Far influence must be non-negative"); } // ----------------------------------------------------------------------- #[test] fn test_mass_conservation() { // Tight budget: 100 000 bytes. Five splats each with 50 000-byte mass // and opacity 1.0 → total = 250 000 > budget, must be scaled down. let mut field = SplatField::new(100_000, 0.5, 0.05); for i in 0..5 { field.add_splat(i as f64, 1.0, 1.0, 50_000, 1); } field.step(0.1); let total_energy: f64 = field .splats .values() .map(|s| s.opacity * s.mass as f64) .sum(); assert!( total_energy <= 100_000.0 * 1.001, // tiny float tolerance "Energy must be within budget after step(): {total_energy:.1}" ); } // ----------------------------------------------------------------------- #[test] fn test_access_heats_splat() { let mut field = make_field(); let id = field.add_splat(0.0, 0.1, 1.0, 1024, 1); let before = field.splats[&id].opacity; field.access(id); let after = field.splats[&id].opacity; assert!( after > before, "Access must raise opacity: {before:.4} → {after:.4}" ); assert_eq!(field.splats[&id].access_count, 1); } // ----------------------------------------------------------------------- #[test] fn test_decay_cools_splat() { let mut field = make_field(); // Start hot; no access; no neighbours. let id = field.add_splat(0.0, 1.0, 1.0, 1024, 1); for _ in 0..50 { field.step(0.1); } let final_opacity = field.splats[&id].opacity; assert!( final_opacity < 1.0, "Splat must cool down over 50 steps without access: opacity={final_opacity:.4}" ); } // ----------------------------------------------------------------------- #[test] fn test_split_creates_children() { let mut field = make_field(); let parent_id = field.add_splat(5.0, 0.5, 2.0, 8192, 42); // Sub-opacities with high coefficient of variation → forces a split. let sub_ops = [0.05, 0.95, 0.1, 0.9]; let children = field .try_split(parent_id, &sub_ops) .expect("Split should succeed with high CV"); assert_eq!(children.len(), 4, "Should create one child per sub-opacity"); // Each child must point back to the parent. for &child_id in &children { let child = &field.splats[&child_id]; assert_eq!( child.parent_id, Some(parent_id), "Child {child_id} must reference parent {parent_id}" ); } // Parent must record the children. let parent = &field.splats[&parent_id]; assert_eq!( parent.child_ids, children, "Parent child_ids must match returned IDs" ); } // ----------------------------------------------------------------------- #[test] fn test_merge_combines_splats() { let mut field = make_field(); // Two nearly identical splats at close positions so influence is high. let a = field.add_splat(0.0, 0.5, 10.0, 512, 1); let b = field.add_splat(0.1, 0.5, 10.0, 512, 1); let merged = field .try_merge(&[a, b]) .expect("Merge should succeed for similar, close splats"); // Originals must be gone. assert!( !field.splats.contains_key(&a), "Source splat A must be removed after merge" ); assert!( !field.splats.contains_key(&b), "Source splat B must be removed after merge" ); // Merged splat must exist and have combined mass. let m = &field.splats[&merged]; assert_eq!(m.mass, 1024, "Merged mass must be sum of sources"); assert!( (m.opacity - 0.5).abs() < 0.05, "Merged opacity must be approximately the mean" ); } // ----------------------------------------------------------------------- #[test] fn test_tiled_scan_priority() { let mut field = make_field(); // Cold cluster: positions 0-2, low opacity. for i in 0..3 { field.add_splat(i as f64, 0.05, 1.0, 512, 1); } // Hot cluster: positions 10-12, high opacity. for i in 0..3 { field.add_splat(10.0 + i as f64, 0.95, 1.0, 512, 1); } field.partition_tiles(2); assert_eq!(field.tiles.len(), 2, "Should have exactly 2 tiles"); // The hot tile should have higher scan_priority. let max_priority = field .tiles .iter() .map(|t| t.scan_priority) .fold(f64::NEG_INFINITY, f64::max); let min_priority = field .tiles .iter() .map(|t| t.scan_priority) .fold(f64::INFINITY, f64::min); assert!( max_priority > min_priority, "Hot tile must have higher priority than cold tile: max={max_priority:.3} min={min_priority:.3}" ); // Repeatedly scanning must always pick the hot tile first (it has higher // initial priority and resets to heat baseline after each scan). let first = field.scan_next_tile(1_000).unwrap().clone(); assert!( first.heat > 0.5, "First scanned tile should be the hot one: heat={:.3}", first.heat ); } // ----------------------------------------------------------------------- #[test] fn test_cold_hot_identification() { let mut field = make_field(); // Cold cluster at positions 0-2, hot cluster at positions 100-102. // The 100-unit gap with covariance=1.0 makes cross-cluster Gaussian // influence vanishingly small (≈ exp(-0.5 × 100²) ≈ 0), so the cold // splats cannot be warmed by the hot ones over a handful of steps. let c0 = field.add_splat(0.0, 0.05, 1.0, 512, 1); let c1 = field.add_splat(1.0, 0.08, 1.0, 512, 1); let c2 = field.add_splat(2.0, 0.12, 1.0, 512, 1); // Three hot splats well separated from cold cluster. let h0 = field.add_splat(100.0, 0.85, 1.0, 512, 1); let h1 = field.add_splat(101.0, 0.90, 1.0, 512, 1); let h2 = field.add_splat(102.0, 0.95, 1.0, 512, 1); // Evolve a few steps to exercise the pipeline end-to-end. for _ in 0..5 { field.step(0.1); } let cold = field.get_cold_splats(0.2); let hot = field.get_hot_splats(0.7); // Original cold set must still be cold. for &id in &[c0, c1, c2] { assert!( cold.contains(&id), "Splat {id} should be in the cold list" ); } // Original hot set must still be hot. for &id in &[h0, h1, h2] { assert!( hot.contains(&id), "Splat {id} should be in the hot list" ); } } }