use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::io::{self, Write};
use std::time::Instant;
use crate::stats;
pub const GROWTH_VERSION: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubMsGrowthClass {
Bounded,
AmplificationBounded,
PlateauBounded,
UnboundedOk,
}
impl SubMsGrowthClass {
pub fn as_str(self) -> &'static str {
match self {
SubMsGrowthClass::Bounded => "bounded",
SubMsGrowthClass::AmplificationBounded => "amplification_bounded",
SubMsGrowthClass::PlateauBounded => "plateau_bounded",
SubMsGrowthClass::UnboundedOk => "unbounded_ok",
}
}
}
pub trait SubMsGrowthRecipe {
fn name(&self) -> &str;
fn op_name(&self) -> &str {
"op"
}
fn rounds(&self) -> usize;
fn ops_per_round(&self) -> usize;
fn op(&mut self, round: usize, i: usize);
fn end_round(&mut self, _round: usize) {}
fn disk_bytes(&mut self) -> u64 {
0
}
fn memory_bytes(&mut self) -> u64 {
0
}
fn live_bytes(&mut self) -> u64;
fn structures(&mut self) -> Vec<(String, u64)> {
Vec::new()
}
fn expected(&self) -> (SubMsGrowthClass, f64);
fn compact(&self) -> bool {
false
}
}
#[derive(Debug, Clone)]
pub struct SubMsGrowthRound {
pub round: usize,
pub ops: usize,
pub cumulative_ops: usize,
pub disk_bytes: u64,
pub memory_bytes: u64,
pub total_bytes: u64,
pub live_bytes: u64,
pub amplification: f64,
pub structures: BTreeMap<String, u64>,
pub p50_ns: u64,
pub p99_ns: u64,
pub max_ns: u64,
}
#[derive(Debug, Clone)]
pub struct SubMsGrowthVerdict {
pub class: SubMsGrowthClass,
pub bound: f64,
pub holds: bool,
pub observed: f64,
pub summary: String,
}
#[derive(Debug, Clone)]
pub struct SubMsGrowthReport {
pub workload: String,
pub lang: String,
pub op_name: String,
pub rounds: Vec<SubMsGrowthRound>,
pub verdict: SubMsGrowthVerdict,
pub compact: bool,
pub meta: BTreeMap<String, String>,
}
pub fn grow(recipe: &mut dyn SubMsGrowthRecipe, lang: &str) -> SubMsGrowthReport {
let r = recipe.rounds().max(1);
let ops = recipe.ops_per_round().max(1);
let mut rounds = Vec::with_capacity(r);
let mut cumulative = 0usize;
for round in 1..=r {
let mut samples = Vec::with_capacity(ops);
for i in 0..ops {
let t = Instant::now();
recipe.op(round, i);
samples.push(t.elapsed().as_nanos() as u64);
}
recipe.end_round(round);
cumulative += ops;
samples.sort_unstable();
let disk = recipe.disk_bytes();
let memory = recipe.memory_bytes();
let total = disk + memory;
let live = recipe.live_bytes();
let amplification = if live > 0 {
total as f64 / live as f64
} else {
0.0
};
let structures: BTreeMap<String, u64> = recipe.structures().into_iter().collect();
rounds.push(SubMsGrowthRound {
round,
ops,
cumulative_ops: cumulative,
disk_bytes: disk,
memory_bytes: memory,
total_bytes: total,
live_bytes: live,
amplification,
structures,
p50_ns: stats::percentile(&samples, 0.50),
p99_ns: stats::percentile(&samples, 0.99),
max_ns: samples.last().copied().unwrap_or(0),
});
}
let verdict = compute_verdict(recipe.expected(), &rounds);
SubMsGrowthReport {
workload: recipe.name().to_string(),
lang: lang.to_string(),
op_name: recipe.op_name().to_string(),
rounds,
verdict,
compact: recipe.compact(),
meta: BTreeMap::new(),
}
}
fn compute_verdict(
(class, bound): (SubMsGrowthClass, f64),
rounds: &[SubMsGrowthRound],
) -> SubMsGrowthVerdict {
match class {
SubMsGrowthClass::Bounded => {
let peak = rounds.iter().map(|r| r.total_bytes).max().unwrap_or(0) as f64;
SubMsGrowthVerdict {
class,
bound,
holds: peak <= bound,
observed: peak,
summary: format!(
"peak footprint {} bytes vs bound {} bytes",
peak as u64, bound as u64
),
}
}
SubMsGrowthClass::AmplificationBounded => {
let amp = rounds
.iter()
.filter(|r| r.live_bytes > 0)
.map(|r| r.amplification)
.fold(0.0_f64, f64::max);
SubMsGrowthVerdict {
class,
bound,
holds: amp <= bound,
observed: amp,
summary: format!(
"max footprint/live amplification {amp:.2}x vs ceiling {bound:.2}x"
),
}
}
SubMsGrowthClass::PlateauBounded => {
let n = rounds.len();
let mid = rounds.get(n / 2).map(|r| r.total_bytes).unwrap_or(0).max(1) as f64;
let last = rounds.last().map(|r| r.total_bytes).unwrap_or(0) as f64;
let ratio = last / mid;
SubMsGrowthVerdict {
class,
bound,
holds: ratio <= bound,
observed: ratio,
summary: format!(
"footprint grew {ratio:.2}x from mid-run to round {n} (ceiling {bound:.2}x)"
),
}
}
SubMsGrowthClass::UnboundedOk => SubMsGrowthVerdict {
class,
bound,
holds: true,
observed: 0.0,
summary: "growth expected and unbounded by design".to_string(),
},
}
}
pub fn assert_growth_holds(report: &SubMsGrowthReport) -> Result<(), String> {
if report.verdict.holds {
Ok(())
} else {
Err(format!(
"{} growth gate breached: {}",
report.workload, report.verdict.summary
))
}
}
pub fn growth_to_json<W: Write>(report: &SubMsGrowthReport, out: &mut W) -> io::Result<()> {
let mut s = String::with_capacity(256 + report.rounds.len() * 128);
s.push('{');
s.push_str("\"kind\":\"growth\",");
kv_str(&mut s, "workload", &report.workload);
s.push(',');
kv_str(&mut s, "lang", &report.lang);
s.push(',');
kv_str(&mut s, "op", &report.op_name);
s.push(',');
let _ = write!(s, "\"growth_version\":{GROWTH_VERSION},");
s.push_str("\"verdict\":{");
kv_str(&mut s, "class", report.verdict.class.as_str());
s.push(',');
let _ = write!(s, "\"bound\":{:.4},", report.verdict.bound);
let _ = write!(s, "\"holds\":{},", report.verdict.holds);
let _ = write!(s, "\"observed\":{:.4},", report.verdict.observed);
kv_str(&mut s, "summary", &report.verdict.summary);
s.push_str("},");
let _ = write!(s, "\"compact\":{},", report.compact);
s.push_str("\"rounds\":[");
for (i, r) in report.rounds.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push('{');
let _ = write!(s, "\"round\":{},", r.round);
let _ = write!(s, "\"ops\":{},", r.ops);
let _ = write!(s, "\"cumulative_ops\":{},", r.cumulative_ops);
let _ = write!(s, "\"disk_bytes\":{},", r.disk_bytes);
let _ = write!(s, "\"memory_bytes\":{},", r.memory_bytes);
let _ = write!(s, "\"total_bytes\":{},", r.total_bytes);
let _ = write!(s, "\"live_bytes\":{},", r.live_bytes);
let _ = write!(s, "\"amplification\":{:.4},", r.amplification);
s.push_str("\"structures\":{");
for (j, (name, count)) in r.structures.iter().enumerate() {
if j > 0 {
s.push(',');
}
json_str(&mut s, name);
let _ = write!(s, ":{count}");
}
s.push_str("},");
let _ = write!(s, "\"p50_ns\":{},", r.p50_ns);
let _ = write!(s, "\"p99_ns\":{},", r.p99_ns);
let _ = write!(s, "\"max_ns\":{}", r.max_ns);
s.push('}');
}
s.push(']');
if !report.meta.is_empty() {
s.push_str(",\"meta\":{");
for (i, (k, v)) in report.meta.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_str(&mut s, k);
s.push(':');
json_str(&mut s, v);
}
s.push('}');
}
s.push('}');
out.write_all(s.as_bytes())
}
fn kv_str(out: &mut String, k: &str, v: &str) {
json_str(out, k);
out.push(':');
json_str(out, v);
}
#[cfg(test)]
#[path = "growth_tests.rs"]
mod tests;
fn json_str(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
}