Spaces:
Runtime error
Runtime error
| //! 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. | |
| 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<u32>, | |
| /// Parent splat ID when this splat was produced by a merge. | |
| pub parent_id: Option<u32>, | |
| } | |
| /// A tile — a contiguous position-range bucket of splats scanned together. | |
| pub struct Tile { | |
| pub id: u32, | |
| pub splat_ids: Vec<u32>, | |
| /// 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<u32, Splat>, | |
| tiles: Vec<Tile>, | |
| 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`]. | |
| 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<u32> = self.splats.keys().copied().collect(); | |
| // Phase 1: compute new opacities. | |
| let mut new_opacities: HashMap<u32, f64> = 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<Vec<u32>> { | |
| 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::<f64>() / n; | |
| if mean == 0.0 { | |
| return None; | |
| } | |
| let variance: f64 = | |
| sub_opacities.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / 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<u32> { | |
| if ids.len() < 2 { | |
| return None; | |
| } | |
| // Gather splat snapshots. | |
| let splats: Vec<Splat> = 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::<f64>() | |
| / 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::<f64>() / splats.len() as f64; | |
| let merged_opacity = mean_opacity; | |
| let merged_covariance = | |
| splats.iter().map(|s| s.covariance).sum::<f64>() / 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<Tile> = (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<u32> { | |
| 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<u32> { | |
| 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. | |
| fn growth_fn(activation: f64) -> f64 { | |
| let x = (activation - 0.5) / 0.15; | |
| (-0.5 * x * x).exp() | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Tests | |
| // --------------------------------------------------------------------------- | |
| 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 | |
| ) | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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"); | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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}" | |
| ); | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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); | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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}" | |
| ); | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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" | |
| ); | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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" | |
| ); | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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 | |
| ); | |
| } | |
| // ----------------------------------------------------------------------- | |
| 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" | |
| ); | |
| } | |
| } | |
| } | |