use rand::{Rng, SeedableRng, rngs::StdRng};
use crate::{
env::games::bucket_brigade::BucketBrigadeMaEnv,
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],
}
}
}
enum BrPolicy {
Uniform,
AlwaysRest,
Specialist,
Firefighter(FirefighterParams),
}
impl BrPolicy {
fn action(
&self,
flat_obs: &[f32],
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, BR_AGENT, num_agents, num_houses),
BrPolicy::Firefighter(p) => p.action(flat_obs, BR_AGENT, 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 mut team_return_sum = 0.0_f64;
let mut br_return_sum = 0.0_f64;
let mut total_steps = 0_usize;
for &seed in episode_seeds {
let mut obs = env.reset(Some(seed));
for _ in 0..step_cap {
let actions: Vec<[u8; 3]> = (0..num_agents)
.map(|a| {
let act = if a == BR_AGENT {
policy.action(&obs[a], num_agents, num_houses, rng)
} else {
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;
br_return_sum += res.rewards[BR_AGENT] as f64;
total_steps += 1;
obs = res.observations;
if res.done {
break;
}
}
}
OracleEval { episodes: episode_seeds.len(), total_steps, team_return_sum, br_return_sum }
}
#[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 }
}
#[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]);
}
}