use crate::grid::Dims;
use std::path::PathBuf;
use std::sync::OnceLock;
pub const DEFAULT_BUDGET_FRACTION: f64 = 0.5;
const ELEM_BYTES: u64 = 8;
const WARM_FACTOR: f64 = 2.0;
const FALLBACK_PHYSICAL_BYTES: u64 = 8 * 1024 * 1024 * 1024;
#[must_use]
pub fn physical_ram_bytes() -> Option<u64> {
static CACHE: OnceLock<Option<u64>> = OnceLock::new();
*CACHE.get_or_init(query_physical_ram)
}
#[cfg(target_os = "linux")]
fn query_physical_ram() -> Option<u64> {
let txt = std::fs::read_to_string("/proc/meminfo").ok()?;
for line in txt.lines() {
if let Some(rest) = line.strip_prefix("MemTotal:") {
let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
return Some(kb * 1024);
}
}
None
}
#[cfg(target_os = "macos")]
fn query_physical_ram() -> Option<u64> {
let out = std::process::Command::new("sysctl")
.arg("-n")
.arg("hw.memsize")
.output()
.ok()?;
String::from_utf8(out.stdout).ok()?.trim().parse().ok()
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
fn query_physical_ram() -> Option<u64> {
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MemoryBudget {
limit_bytes: u64,
}
impl MemoryBudget {
#[must_use]
pub fn bytes(limit_bytes: u64) -> Self {
Self { limit_bytes }
}
#[must_use]
pub fn fraction_of_physical(fraction: f64) -> Self {
let phys = physical_ram_bytes().unwrap_or(FALLBACK_PHYSICAL_BYTES);
let frac = fraction.clamp(0.0, 1.0);
Self {
limit_bytes: (phys as f64 * frac) as u64,
}
}
#[must_use]
pub fn unlimited() -> Self {
Self {
limit_bytes: u64::MAX,
}
}
#[must_use]
pub fn limit_bytes(&self) -> u64 {
self.limit_bytes
}
#[must_use]
pub fn is_unlimited(&self) -> bool {
self.limit_bytes == u64::MAX
}
}
impl Default for MemoryBudget {
fn default() -> Self {
Self::fraction_of_physical(DEFAULT_BUDGET_FRACTION)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildMode {
InCore,
Spilled,
}
#[must_use]
pub fn live_set_bytes(dims: Dims, n_cubes: usize) -> u64 {
let cells = dims.cell_count() as u64;
let zcorn = cells.saturating_mul(8).saturating_mul(ELEM_BYTES);
let cubes = (n_cubes as u64)
.saturating_mul(cells)
.saturating_mul(ELEM_BYTES);
(((zcorn.saturating_add(cubes)) as f64) * WARM_FACTOR) as u64
}
#[must_use]
pub fn decide_mode(dims: Dims, n_cubes: usize, budget: MemoryBudget) -> (BuildMode, u64) {
let estimate = live_set_bytes(dims, n_cubes);
let mode = if estimate > budget.limit_bytes() {
BuildMode::Spilled
} else {
BuildMode::InCore
};
(mode, estimate)
}
#[derive(Debug, Clone)]
pub struct SpillNotice {
pub cells: usize,
pub budget_bytes: u64,
pub estimate_bytes: u64,
pub store_path: PathBuf,
}
impl SpillNotice {
pub fn warn(&self) {
eprintln!(
"petekstatic: OUT-OF-CORE mode — live-set estimate {} MiB exceeds budget {} MiB \
({} cells); spilling geometry + cubes (f32) to {}",
self.estimate_bytes >> 20,
self.budget_bytes >> 20,
self.cells,
self.store_path.display(),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unlimited_never_spills() {
let dims = Dims::new(1000, 1000, 100).unwrap(); let (mode, est) = decide_mode(dims, 3, MemoryBudget::unlimited());
assert_eq!(mode, BuildMode::InCore);
assert!(est > 0);
}
#[test]
fn tiny_budget_forces_spill() {
let dims = Dims::new(100, 100, 100).unwrap(); let (mode, est) = decide_mode(dims, 3, MemoryBudget::bytes(1024));
assert_eq!(mode, BuildMode::Spilled);
assert!(est > 100 * 1024 * 1024, "estimate {est} looks too small");
}
#[test]
fn generous_budget_stays_in_core() {
let dims = Dims::new(50, 50, 10).unwrap(); let (mode, _) = decide_mode(dims, 3, MemoryBudget::bytes(1024 * 1024 * 1024));
assert_eq!(mode, BuildMode::InCore);
}
#[test]
fn fraction_is_clamped_and_positive() {
assert!(MemoryBudget::fraction_of_physical(0.5).limit_bytes() > 0);
let whole = MemoryBudget::fraction_of_physical(2.0).limit_bytes();
let phys = physical_ram_bytes().unwrap_or(FALLBACK_PHYSICAL_BYTES);
assert_eq!(whole, phys);
}
}