use crate::metrics::{compute_memory, compute_persistence, compute_storage};
use crate::observation::Observation;
use crate::types::NullEnsembles;
use crate::universe::InformationUniverse;
use rand::RngExt;
use rand::SeedableRng;
use rand::rngs::StdRng;
pub struct CalibrationConfig {
pub percentile: f64,
pub floor_persistence: f64,
pub floor_storage: f64,
pub floor_memory: f64,
pub max_delta: usize,
pub n_shuffles: usize,
pub seed: u64,
}
impl Default for CalibrationConfig {
fn default() -> Self {
Self {
percentile: 95.0,
floor_persistence: 0.01,
floor_storage: 0.01,
floor_memory: 0.01,
max_delta: 15,
n_shuffles: 10,
seed: 42,
}
}
}
pub fn generate_null_trajectories<U: InformationUniverse>(
universe: &U,
n_universes: usize,
n_ensemble: usize,
steps: usize,
seed: u64,
) -> NullEnsembles<U>
where
<U::Observation as Observation<U::State>>::Output: Clone,
{
let mut rng = StdRng::seed_from_u64(seed);
let state_space = universe.state_space();
let schedule = universe.schedule();
let observer = universe.observation();
let mut null_ensembles = Vec::with_capacity(n_universes);
for i in 0..n_universes {
let rules = universe.null_rules(&mut rng);
let n_pool = state_space.len();
let n_ensemble = n_ensemble.min(n_pool);
let mut init_indices: Vec<usize> = (0..n_pool).collect();
for j in 0..n_ensemble {
let k = rng.random_range(j..n_pool);
init_indices.swap(j, k);
}
let initial_states: Vec<U::State> = init_indices
.iter()
.take(n_ensemble)
.map(|&idx| state_space[idx].clone())
.collect();
let ensemble = generate_trajectories(
&initial_states,
&rules,
steps,
schedule,
observer,
seed + i as u64 * 1000,
);
null_ensembles.push(ensemble);
}
null_ensembles
}
pub fn generate_trajectories<S, R, K, O>(
initial_states: &[S],
rules: &[R],
steps: usize,
schedule: &K,
observer: &O,
base_seed: u64,
) -> Vec<Vec<O::Output>>
where
S: crate::state::State,
R: crate::rules::Rule<S>,
K: crate::schedule::Schedule<S, R>,
O: crate::observation::Observation<S>,
O::Output: Clone,
{
let mut trajectories = Vec::with_capacity(initial_states.len());
for (i, initial) in initial_states.iter().enumerate() {
let seed = base_seed + i as u64 * 137;
let mut rng = StdRng::seed_from_u64(seed);
let mut observations = Vec::with_capacity(steps + 1);
observations.push(observer.observe(initial));
let mut current = initial.clone();
for _ in 0..steps {
current = schedule.step(¤t, rules, &mut rng);
observations.push(observer.observe(¤t));
}
trajectories.push(observations);
}
trajectories
}
#[derive(Debug, Clone)]
pub struct CalibrationResult {
pub persistence_threshold: f64,
pub storage_threshold: f64,
pub memory_threshold: f64,
pub null_persistence: NullStats,
pub null_storage: NullStats,
pub null_memory: NullStats,
pub percentile: f64,
}
#[derive(Debug, Clone)]
pub struct NullStats {
pub mean: f64,
pub std: f64,
pub scores: Vec<f64>,
}
impl NullStats {
pub fn empirical_p(&self, observed_value: f64) -> f64 {
let count = self.scores.iter().filter(|&&s| s >= observed_value).count();
if self.scores.is_empty() {
1.0
} else {
count as f64 / self.scores.len() as f64
}
}
}
pub fn calibrate_thresholds<T: Eq + std::hash::Hash + Clone>(
null_ensembles: &[Vec<Vec<T>>],
config: &CalibrationConfig,
) -> CalibrationResult {
let mut persistence_scores = Vec::with_capacity(null_ensembles.len());
let mut storage_scores = Vec::with_capacity(null_ensembles.len());
let mut memory_scores = Vec::with_capacity(null_ensembles.len());
for ensemble in null_ensembles {
persistence_scores.push(compute_persistence(
ensemble,
1,
config.n_shuffles,
config.seed,
));
storage_scores.push(compute_storage(
ensemble,
config.max_delta,
config.n_shuffles,
config.seed,
));
memory_scores.push(compute_memory(
ensemble,
config.max_delta,
config.n_shuffles,
config.seed,
));
}
let persistence_threshold =
percentile_value(&persistence_scores, config.percentile).max(config.floor_persistence);
let storage_threshold =
percentile_value(&storage_scores, config.percentile).max(config.floor_storage);
let memory_threshold =
percentile_value(&memory_scores, config.percentile).max(config.floor_memory);
CalibrationResult {
persistence_threshold,
storage_threshold,
memory_threshold,
null_persistence: NullStats::from_scores(persistence_scores),
null_storage: NullStats::from_scores(storage_scores),
null_memory: NullStats::from_scores(memory_scores),
percentile: config.percentile,
}
}
fn percentile_value(scores: &[f64], percentile: f64) -> f64 {
if scores.is_empty() {
return 0.0;
}
if scores.len() == 1 {
return scores[0];
}
let mut sorted: Vec<f64> = scores.to_vec();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let k = (percentile / 100.0) * (sorted.len() - 1) as f64;
let lo = k.floor() as usize;
let hi = k.ceil() as usize;
if lo == hi {
sorted[lo]
} else {
let frac = k - lo as f64;
sorted[lo] * (1.0 - frac) + sorted[hi] * frac
}
}
impl NullStats {
fn from_scores(scores: Vec<f64>) -> Self {
let n = scores.len() as f64;
let mean = if n > 0.0 {
scores.iter().sum::<f64>() / n
} else {
0.0
};
let std = if n > 1.0 {
let variance = scores.iter().map(|s| (s - mean).powi(2)).sum::<f64>() / (n - 1.0);
variance.sqrt()
} else {
0.0
};
Self { mean, std, scores }
}
}
pub fn calibrate<U: InformationUniverse>(
universe: &U,
n_null_universes: usize,
n_ensemble: usize,
steps: usize,
config: &CalibrationConfig,
) -> CalibrationResult
where
<U::Observation as Observation<U::State>>::Output: Eq + std::hash::Hash + Clone,
{
let null_ensembles =
generate_null_trajectories(universe, n_null_universes, n_ensemble, steps, config.seed);
calibrate_thresholds(&null_ensembles, config)
}