use rand::{Rng, SeedableRng, rngs::StdRng};
use serde::Serialize;
use crate::{
env::games::bucket_brigade::{BucketBrigadeMaEnv, registry},
multi_agent::bucket_brigade_baselines::specialist_action,
};
const HOUSE_BURNING: u8 = 1;
const MODE_REST: i64 = 0;
const MODE_WORK: i64 = 1;
pub const BR_AGENT: usize = 0;
#[derive(Debug, Clone, Copy)]
pub struct OracleEval {
pub episodes: usize,
pub total_steps: usize,
pub team_return_sum: f64,
pub br_return_sum: f64,
}
impl OracleEval {
pub fn per_step_team(&self) -> f64 {
if self.total_steps == 0 {
f64::NAN
} else {
self.team_return_sum / self.total_steps as f64
}
}
pub fn per_step_br(&self) -> f64 {
if self.total_steps == 0 {
f64::NAN
} else {
self.br_return_sum / self.total_steps as f64
}
}
pub fn per_episode_team(&self) -> f64 {
if self.episodes == 0 {
f64::NAN
} else {
self.team_return_sum / self.episodes as f64
}
}
pub fn per_episode_br(&self) -> f64 {
if self.episodes == 0 {
f64::NAN
} else {
self.br_return_sum / self.episodes as f64
}
}
pub fn mean_ep_len(&self) -> f64 {
if self.episodes == 0 {
f64::NAN
} else {
self.total_steps as f64 / self.episodes as f64
}
}
}
fn uniform_action(num_houses: usize, rng: &mut StdRng) -> [i64; 3] {
[
rng.random_range(0..num_houses) as i64,
rng.random_range(0..2),
rng.random_range(0..2),
]
}
fn house_state(flat_obs: &[f32], h: usize) -> u8 {
flat_obs[1 + h] as u8
}
#[derive(Debug, Clone, Copy)]
pub struct FirefighterParams {
pub scope_owned_only: bool,
pub work_prob: f32,
}
impl FirefighterParams {
fn action(
&self,
flat_obs: &[f32],
agent_id: usize,
num_agents: usize,
num_houses: usize,
rng: &mut StdRng,
) -> [i64; 3] {
let mut target: Option<usize> = None;
for h in 0..num_houses {
if self.scope_owned_only && h % num_agents != agent_id {
continue;
}
if house_state(flat_obs, h) == HOUSE_BURNING {
target = Some(h);
break;
}
}
match target {
Some(h) if rng.random::<f32>() < self.work_prob => [h as i64, MODE_WORK, MODE_WORK],
_ => [0, MODE_REST, MODE_REST],
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum BrPolicy {
Uniform,
AlwaysRest,
Specialist,
Firefighter(FirefighterParams),
}
impl BrPolicy {
fn action(
&self,
flat_obs: &[f32],
agent_id: usize,
num_agents: usize,
num_houses: usize,
rng: &mut StdRng,
) -> [i64; 3] {
match self {
BrPolicy::Uniform => uniform_action(num_houses, rng),
BrPolicy::AlwaysRest => [0, MODE_REST, MODE_REST],
BrPolicy::Specialist => specialist_action(flat_obs, agent_id, num_agents, num_houses),
BrPolicy::Firefighter(p) => p.action(flat_obs, agent_id, num_agents, num_houses, rng),
}
}
}
fn evaluate(
env: &mut BucketBrigadeMaEnv,
policy: &BrPolicy,
num_agents: usize,
num_houses: usize,
episode_seeds: &[u64],
rng: &mut StdRng,
step_cap: usize,
) -> OracleEval {
let coalition = [(BR_AGENT, *policy)];
evaluate_coalition(env, &coalition, num_agents, num_houses, episode_seeds, rng, step_cap).eval
}
#[derive(Debug, Clone)]
pub struct CoalitionEval {
pub eval: OracleEval,
pub per_episode_team_per_step: Vec<f64>,
}
#[allow(clippy::too_many_arguments)]
pub fn evaluate_coalition(
env: &mut BucketBrigadeMaEnv,
coalition: &[(usize, BrPolicy)],
num_agents: usize,
num_houses: usize,
episode_seeds: &[u64],
rng: &mut StdRng,
step_cap: usize,
) -> CoalitionEval {
let mut team_return_sum = 0.0_f64;
let mut br_return_sum = 0.0_f64;
let mut total_steps = 0_usize;
let mut per_episode_team_per_step = Vec::with_capacity(episode_seeds.len());
for &seed in episode_seeds {
let mut obs = env.reset(Some(seed));
let mut ep_team = 0.0_f64;
let mut ep_steps = 0_usize;
for _ in 0..step_cap {
let actions: Vec<[u8; 3]> = (0..num_agents)
.map(|a| {
let act = match coalition.iter().find(|(id, _)| *id == a) {
Some((id, policy)) => {
policy.action(&obs[a], *id, num_agents, num_houses, rng)
}
None => uniform_action(num_houses, rng),
};
[act[0] as u8, act[1] as u8, act[2] as u8]
})
.collect();
let res = env.step(&actions);
let step_team: f64 = res.rewards.iter().map(|&r| r as f64).sum();
team_return_sum += step_team;
ep_team += step_team;
for (id, _) in coalition {
br_return_sum += res.rewards[*id] as f64;
}
total_steps += 1;
ep_steps += 1;
obs = res.observations;
if res.done {
break;
}
}
per_episode_team_per_step.push(if ep_steps == 0 {
0.0
} else {
ep_team / ep_steps as f64
});
}
CoalitionEval {
eval: OracleEval {
episodes: episode_seeds.len(),
total_steps,
team_return_sum,
br_return_sum,
},
per_episode_team_per_step,
}
}
pub fn bootstrap_mean_ci(
values: &[f64],
n_boot: usize,
alpha: f64,
rng: &mut StdRng,
) -> (f64, f64) {
let n = values.len();
if n == 0 || n_boot == 0 {
return (f64::NAN, f64::NAN);
}
let mut means: Vec<f64> = Vec::with_capacity(n_boot);
for _ in 0..n_boot {
let mut acc = 0.0_f64;
for _ in 0..n {
let idx = rng.random_range(0..n);
acc += values[idx];
}
means.push(acc / n as f64);
}
means.sort_by(|a, b| a.partial_cmp(b).unwrap());
let lo_q = alpha / 2.0;
let hi_q = 1.0 - alpha / 2.0;
let quantile = |q: f64| -> f64 {
let rank = (q * (n_boot as f64 - 1.0)).round() as usize;
means[rank.min(n_boot - 1)]
};
(quantile(lo_q), quantile(hi_q))
}
#[derive(Debug, Clone)]
pub struct OracleRow {
pub label: String,
pub eval: OracleEval,
}
#[derive(Debug, Clone)]
pub struct OracleReport {
pub baseline: OracleRow,
pub candidates: Vec<OracleRow>,
pub best_idx: usize,
}
impl OracleReport {
pub fn best(&self) -> &OracleRow {
&self.candidates[self.best_idx]
}
pub fn team_gap_per_step(&self) -> f64 {
self.best().eval.per_step_team() - self.baseline.eval.per_step_team()
}
pub fn team_gap_fraction(&self) -> f64 {
let base = self.baseline.eval.per_step_team();
if base == 0.0 {
return f64::NAN;
}
self.team_gap_per_step() / base.abs()
}
}
#[allow(clippy::too_many_arguments)]
pub fn run_oracle(
env: &mut BucketBrigadeMaEnv,
num_agents: usize,
num_houses: usize,
eval_episodes: usize,
search_episodes: usize,
num_search: usize,
seed: u64,
step_cap: usize,
) -> OracleReport {
let eval_seeds: Vec<u64> = (0..eval_episodes as u64).map(|i| seed ^ (0x9E3779B9 ^ i)).collect();
let search_seeds: Vec<u64> =
(0..search_episodes as u64).map(|i| seed ^ (0x85EBCA6B ^ i)).collect();
fn score_eval(
env: &mut BucketBrigadeMaEnv,
policy: &BrPolicy,
num_agents: usize,
num_houses: usize,
eval_seeds: &[u64],
seed: u64,
step_cap: usize,
) -> OracleEval {
let mut rng = StdRng::seed_from_u64(seed);
evaluate(env, policy, num_agents, num_houses, eval_seeds, &mut rng, step_cap)
}
macro_rules! score {
($policy:expr) => {
score_eval(env, &$policy, num_agents, num_houses, &eval_seeds, seed, step_cap)
};
}
let baseline =
OracleRow { label: "uniform (baseline)".to_string(), eval: score!(BrPolicy::Uniform) };
let mut candidates: Vec<OracleRow> =
vec![OracleRow { label: "uniform".to_string(), eval: baseline.eval }];
candidates
.push(OracleRow { label: "always_rest".to_string(), eval: score!(BrPolicy::AlwaysRest) });
candidates
.push(OracleRow { label: "specialist".to_string(), eval: score!(BrPolicy::Specialist) });
candidates.push(OracleRow {
label: "firefighter[owned, work=1.0]".to_string(),
eval: score!(BrPolicy::Firefighter(FirefighterParams {
scope_owned_only: true,
work_prob: 1.0,
})),
});
candidates.push(OracleRow {
label: "firefighter[any, work=1.0]".to_string(),
eval: score!(BrPolicy::Firefighter(FirefighterParams {
scope_owned_only: false,
work_prob: 1.0,
})),
});
let mut search_rng = StdRng::seed_from_u64(seed ^ 0xD1B54A32);
let mut best_params: Option<FirefighterParams> = None;
let mut best_search_team = f64::NEG_INFINITY;
for _ in 0..num_search {
let params = FirefighterParams {
scope_owned_only: search_rng.random::<bool>(),
work_prob: search_rng.random::<f32>(),
};
let mut rng = StdRng::seed_from_u64(seed);
let eval = evaluate(
env,
&BrPolicy::Firefighter(params),
num_agents,
num_houses,
&search_seeds,
&mut rng,
step_cap,
);
if eval.per_step_team() > best_search_team {
best_search_team = eval.per_step_team();
best_params = Some(params);
}
}
if let Some(params) = best_params {
candidates.push(OracleRow {
label: format!(
"search_best firefighter[{}, work={:.3}]",
if params.scope_owned_only {
"owned"
} else {
"any"
},
params.work_prob
),
eval: score!(BrPolicy::Firefighter(params)),
});
}
let best_idx = candidates
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
a.eval.per_step_team().partial_cmp(&b.eval.per_step_team()).unwrap()
})
.map(|(i, _)| i)
.unwrap_or(0);
OracleReport { baseline, candidates, best_idx }
}
#[derive(Debug, Clone)]
pub struct CoalitionRow {
pub label: String,
pub eval: OracleEval,
pub per_episode_team_per_step: Vec<f64>,
}
#[derive(Debug, Clone)]
pub struct CoalitionOracleReport {
pub k: usize,
pub baseline: CoalitionRow,
pub candidates: Vec<CoalitionRow>,
pub best_idx: usize,
pub gap_mean: f64,
pub gap_ci_lo: f64,
pub gap_ci_hi: f64,
}
impl CoalitionOracleReport {
pub fn best(&self) -> &CoalitionRow {
&self.candidates[self.best_idx]
}
pub fn team_gap_per_step(&self) -> f64 {
self.best().eval.per_step_team() - self.baseline.eval.per_step_team()
}
pub fn team_gap_fraction(&self) -> f64 {
let base = self.baseline.eval.per_step_team();
if base == 0.0 {
return f64::NAN;
}
self.team_gap_per_step() / base.abs()
}
pub fn is_k_star(&self) -> bool {
self.gap_ci_lo > 0.0
}
}
fn coalition_candidates(k: usize, search_best: FirefighterParams) -> Vec<(String, Vec<BrPolicy>)> {
let homogeneous = |label: &str, p: BrPolicy| (label.to_string(), vec![p; k]);
let ff = |owned: bool| {
BrPolicy::Firefighter(FirefighterParams { scope_owned_only: owned, work_prob: 1.0 })
};
let mut out = vec![
homogeneous("all_always_rest", BrPolicy::AlwaysRest),
homogeneous("all_specialist", BrPolicy::Specialist),
homogeneous("all_firefighter[owned,work=1.0]", ff(true)),
homogeneous("all_firefighter[any,work=1.0]", ff(false)),
homogeneous(
&format!(
"all_search_best[{},work={:.3}]",
if search_best.scope_owned_only {
"owned"
} else {
"any"
},
search_best.work_prob
),
BrPolicy::Firefighter(search_best),
),
];
if k >= 2 {
let mut hero_plus_specialists = vec![ff(false)];
hero_plus_specialists.extend(std::iter::repeat_n(BrPolicy::Specialist, k - 1));
out.push(("hero[any]+specialists".to_string(), hero_plus_specialists));
let mut rest_plus_specialists = vec![BrPolicy::AlwaysRest];
rest_plus_specialists.extend(std::iter::repeat_n(BrPolicy::Specialist, k - 1));
out.push(("rest+specialists".to_string(), rest_plus_specialists));
}
out
}
#[allow(clippy::too_many_arguments)]
pub fn run_coalition_oracle(
env: &mut BucketBrigadeMaEnv,
num_agents: usize,
num_houses: usize,
k: usize,
eval_episodes: usize,
search_episodes: usize,
num_search: usize,
seed: u64,
step_cap: usize,
n_boot: usize,
alpha: f64,
) -> CoalitionOracleReport {
assert!(k >= 1 && k <= num_agents, "coalition size k={k} out of range 1..={num_agents}");
let eval_seeds: Vec<u64> = (0..eval_episodes as u64).map(|i| seed ^ (0x9E3779B9 ^ i)).collect();
let search_seeds: Vec<u64> =
(0..search_episodes as u64).map(|i| seed ^ (0x85EBCA6B ^ i)).collect();
let assign = |policies: &[BrPolicy]| -> Vec<(usize, BrPolicy)> {
policies.iter().enumerate().map(|(a, p)| (a, *p)).collect()
};
let score = |env: &mut BucketBrigadeMaEnv, policies: &[BrPolicy]| -> CoalitionEval {
let coalition = assign(policies);
let mut rng = StdRng::seed_from_u64(seed);
evaluate_coalition(env, &coalition, num_agents, num_houses, &eval_seeds, &mut rng, step_cap)
};
let baseline_eval = {
let mut rng = StdRng::seed_from_u64(seed);
evaluate_coalition(env, &[], num_agents, num_houses, &eval_seeds, &mut rng, step_cap)
};
let baseline = CoalitionRow {
label: "all_uniform (baseline)".to_string(),
eval: baseline_eval.eval,
per_episode_team_per_step: baseline_eval.per_episode_team_per_step,
};
let mut search_rng = StdRng::seed_from_u64(seed ^ 0xD1B54A32);
let mut best_params = FirefighterParams { scope_owned_only: true, work_prob: 1.0 };
let mut best_search_team = f64::NEG_INFINITY;
for _ in 0..num_search {
let params = FirefighterParams {
scope_owned_only: search_rng.random::<bool>(),
work_prob: search_rng.random::<f32>(),
};
let coalition = assign(&vec![BrPolicy::Firefighter(params); k]);
let mut rng = StdRng::seed_from_u64(seed);
let eval = evaluate_coalition(
env,
&coalition,
num_agents,
num_houses,
&search_seeds,
&mut rng,
step_cap,
);
if eval.eval.per_step_team() > best_search_team {
best_search_team = eval.eval.per_step_team();
best_params = params;
}
}
let mut candidates: Vec<CoalitionRow> = Vec::new();
for (label, policies) in coalition_candidates(k, best_params) {
let ev = score(env, &policies);
candidates.push(CoalitionRow {
label,
eval: ev.eval,
per_episode_team_per_step: ev.per_episode_team_per_step,
});
}
let best_idx = candidates
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| {
a.eval.per_step_team().partial_cmp(&b.eval.per_step_team()).unwrap()
})
.map(|(i, _)| i)
.unwrap_or(0);
let gap_series: Vec<f64> = candidates[best_idx]
.per_episode_team_per_step
.iter()
.zip(baseline.per_episode_team_per_step.iter())
.map(|(c, b)| c - b)
.collect();
let gap_mean = if gap_series.is_empty() {
f64::NAN
} else {
gap_series.iter().sum::<f64>() / gap_series.len() as f64
};
let mut boot_rng = StdRng::seed_from_u64(seed ^ 0xA5A5_5A5A);
let (gap_ci_lo, gap_ci_hi) = bootstrap_mean_ci(&gap_series, n_boot, alpha, &mut boot_rng);
CoalitionOracleReport { k, baseline, candidates, best_idx, gap_mean, gap_ci_lo, gap_ci_hi }
}
#[derive(Debug, Clone, Serialize)]
pub struct PhaseKRecord {
pub k: usize,
pub gap_mean: f64,
pub gap_ci_lo: f64,
pub gap_ci_hi: f64,
pub team_gap_per_step: f64,
pub best_coalition_policy: String,
pub is_k_star: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct PhaseCellRecord {
pub cell_tag: String,
pub beta: f32,
pub kappa: f32,
pub c: f32,
pub k_star: Option<usize>,
pub per_k: Vec<PhaseKRecord>,
}
pub fn phase_cell_tag(beta: f32, kappa: f32, c: f32) -> String {
format!("b{beta:.2}_k{kappa:.2}_c{c:.2}")
}
pub fn make_phase_cell_env(
beta: f32,
kappa: f32,
c: f32,
num_agents: usize,
seed: u64,
) -> BucketBrigadeMaEnv {
let mut scenario = registry::get_scenario_by_id("minimal_specialization-v1")
.expect("minimal_specialization-v1 must resolve in the registry");
scenario.prob_fire_spreads_to_neighbor = beta;
scenario.prob_solo_agent_extinguishes_fire = kappa;
scenario.cost_to_work_one_night = c;
BucketBrigadeMaEnv::new(scenario, num_agents, Some(seed))
}
#[allow(clippy::too_many_arguments)]
pub fn run_phase_cell(
beta: f32,
kappa: f32,
c: f32,
num_agents: usize,
num_houses: usize,
k_max: usize,
eval_episodes: usize,
search_episodes: usize,
num_search: usize,
seed: u64,
step_cap: usize,
n_boot: usize,
alpha: f64,
) -> PhaseCellRecord {
let mut per_k = Vec::with_capacity(k_max);
let mut k_star: Option<usize> = None;
for k in 1..=k_max {
let mut env = make_phase_cell_env(beta, kappa, c, num_agents, seed);
let report = run_coalition_oracle(
&mut env,
num_agents,
num_houses,
k,
eval_episodes,
search_episodes,
num_search,
seed,
step_cap,
n_boot,
alpha,
);
let is_star = report.is_k_star();
if is_star && k_star.is_none() {
k_star = Some(k);
}
per_k.push(PhaseKRecord {
k,
gap_mean: report.gap_mean,
gap_ci_lo: report.gap_ci_lo,
gap_ci_hi: report.gap_ci_hi,
team_gap_per_step: report.team_gap_per_step(),
best_coalition_policy: report.best().label.clone(),
is_k_star: is_star,
});
}
PhaseCellRecord { cell_tag: phase_cell_tag(beta, kappa, c), beta, kappa, c, k_star, per_k }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
env::games::bucket_brigade::{NUM_HOUSES, registry},
multi_agent::bucket_brigade_baselines::BucketBrigadeCell,
};
fn make_cell_env(cell: BucketBrigadeCell, num_agents: usize, seed: u64) -> BucketBrigadeMaEnv {
let (beta, kappa, cost) = cell.parameters();
let mut scenario = registry::get_scenario_by_id("minimal_specialization-v1")
.expect("minimal_specialization-v1 must resolve in the registry");
scenario.prob_fire_spreads_to_neighbor = beta;
scenario.prob_solo_agent_extinguishes_fire = kappa;
scenario.cost_to_work_one_night = cost;
BucketBrigadeMaEnv::new(scenario, num_agents, Some(seed))
}
#[test]
fn oracle_runs_and_is_sane() {
let mut env = make_cell_env(BucketBrigadeCell::Beta05, 4, 42);
let num_agents = 4;
let eval_episodes = 30;
let search_episodes = 10;
let num_search = 8;
let seed = 42;
let step_cap = 500;
let report = run_oracle(
&mut env,
num_agents,
NUM_HOUSES,
eval_episodes,
search_episodes,
num_search,
seed,
step_cap,
);
assert!(report.baseline.eval.per_step_team().is_finite());
assert!(report.baseline.eval.per_step_team() < 0.0, "env is a penalty landscape");
assert!(report.baseline.eval.mean_ep_len() > 0.0);
for row in &report.candidates {
assert!(row.eval.per_step_team().is_finite(), "candidate {} non-finite", row.label);
assert!(row.eval.episodes == 30);
}
assert!(report.team_gap_per_step() >= -1e-9, "ceiling must be >= baseline");
}
#[test]
fn firefighter_owned_matches_specialist_on_burning_owned() {
let mut houses = [0u8; NUM_HOUSES];
houses[4] = HOUSE_BURNING; let mut flat = vec![0.0f32; 1 + NUM_HOUSES + 64];
for (i, &h) in houses.iter().enumerate() {
flat[1 + i] = h as f32;
}
let mut rng = StdRng::seed_from_u64(0);
let params = FirefighterParams { scope_owned_only: true, work_prob: 1.0 };
let ff = params.action(&flat, 0, 4, NUM_HOUSES, &mut rng);
let spec = specialist_action(&flat, 0, 4, NUM_HOUSES);
assert_eq!(
ff, spec,
"owned work=1.0 firefighter must equal specialist when owned house burns"
);
assert_eq!(ff, [4, MODE_WORK, MODE_WORK]);
}
}