use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use iroh::EndpointId as NodeId;
use tracing::{debug, warn};
use super::protocol::{CompletedTask, ComputeCallError, ComputeClient, ExecuteRequest};
use super::{CapabilityVector, TaskClass};
#[derive(Debug, Default)]
pub struct CapabilityDirectory {
inner: parking_lot::RwLock<HashMap<NodeId, SeenVector>>,
}
#[derive(Debug, Clone)]
struct SeenVector {
vector: CapabilityVector,
received_at: Instant,
}
impl CapabilityDirectory {
pub fn new() -> Self {
Self::default()
}
pub fn upsert(&self, vector: CapabilityVector) {
self.inner.write().insert(
vector.node_id,
SeenVector {
vector,
received_at: Instant::now(),
},
);
}
pub fn remove(&self, node: &NodeId) {
self.inner.write().remove(node);
}
pub fn get(&self, node: &NodeId, max_age: Duration) -> Option<CapabilityVector> {
self.inner
.read()
.get(node)
.filter(|seen| seen.received_at.elapsed() <= max_age)
.map(|seen| seen.vector.clone())
}
pub fn snapshot(&self, max_age: Duration) -> Vec<CapabilityVector> {
self.inner
.read()
.values()
.filter(|seen| seen.received_at.elapsed() <= max_age)
.map(|seen| seen.vector.clone())
.collect()
}
pub fn len(&self) -> usize {
self.inner.read().len()
}
pub fn is_empty(&self) -> bool {
self.inner.read().is_empty()
}
}
#[derive(Debug, Clone)]
pub struct ScoreWeights {
pub free_cores: f64,
pub ram_free_gib: f64,
pub load_penalty: f64,
pub battery_penalty: f64,
pub accelerator_bonus: f64,
pub reputation_penalty: f64,
}
impl Default for ScoreWeights {
fn default() -> Self {
Self {
free_cores: 10.0,
ram_free_gib: 1.0,
load_penalty: 0.2,
battery_penalty: 1_000.0,
accelerator_bonus: 50.0,
reputation_penalty: 500.0,
}
}
}
#[derive(Debug, Default)]
pub struct ReputationBook {
scores: parking_lot::RwLock<HashMap<NodeId, f64>>,
}
impl ReputationBook {
pub fn new() -> Self {
Self::default()
}
pub fn factor(&self, node: &NodeId) -> f64 {
self.scores.read().get(node).copied().unwrap_or(1.0)
}
pub fn penalize(&self, node: &NodeId) {
let mut scores = self.scores.write();
let entry = scores.entry(*node).or_insert(1.0);
*entry = (*entry * 0.5).max(0.05);
}
pub fn reward(&self, node: &NodeId) {
let mut scores = self.scores.write();
let entry = scores.entry(*node).or_insert(1.0);
*entry = (*entry + 0.05).min(1.0);
}
}
#[derive(Debug, Clone)]
pub struct SchedulerConfig {
pub max_vector_age: Duration,
pub max_attempts: usize,
pub attempt_timeout: Duration,
pub auction_probe_count: usize,
pub probe_timeout: Duration,
pub weights: ScoreWeights,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
max_vector_age: Duration::from_secs(300),
max_attempts: 3,
attempt_timeout: Duration::from_secs(60),
auction_probe_count: 3,
probe_timeout: Duration::from_secs(5),
weights: ScoreWeights::default(),
}
}
}
pub fn score(vector: &CapabilityVector, class: TaskClass, weights: &ScoreWeights) -> Option<f64> {
if !vector.is_candidate_for(class) {
return None;
}
let load = f64::from(vector.cpu_load_pct.min(100));
let free_cores = f64::from(vector.cpu_cores) * (100.0 - load) / 100.0;
let ram_gib = f64::from(vector.ram_free_mb) / 1024.0;
let battery = if vector.on_battery {
weights.battery_penalty
} else {
0.0
};
let accel = if class == TaskClass::Inference && !vector.accelerators.is_empty() {
weights.accelerator_bonus
} else {
0.0
};
Some(
weights.free_cores * free_cores + weights.ram_free_gib * ram_gib + accel
- weights.load_penalty * load
- battery,
)
}
#[derive(Debug, Clone)]
pub struct Delegated {
pub executor: NodeId,
pub completed: CompletedTask,
}
#[derive(Debug, thiserror::Error)]
pub enum ScheduleError {
#[error("no candidate node advertises capacity for this task class")]
NoCandidates,
#[error("task failed on {executor}: {error}")]
TaskFailed {
executor: NodeId,
error: super::runtime::TaskError,
},
#[error("all {} delegation attempts failed", attempts.len())]
AllAttemptsFailed {
attempts: Vec<(NodeId, ComputeCallError)>,
},
#[error("redundant execution diverged with no majority")]
Divergent,
}
#[derive(Debug, Clone)]
pub struct RedundantOutcome {
pub delegated: Delegated,
pub agreements: usize,
pub responded: usize,
pub divergent: Vec<NodeId>,
}
#[derive(Debug, Clone)]
pub struct ComputeScheduler {
client: ComputeClient,
directory: Arc<CapabilityDirectory>,
reputation: Arc<ReputationBook>,
local: NodeId,
config: SchedulerConfig,
}
impl ComputeScheduler {
pub fn new(
client: ComputeClient,
directory: Arc<CapabilityDirectory>,
local: NodeId,
config: SchedulerConfig,
) -> Self {
Self::with_reputation(
client,
directory,
Arc::new(ReputationBook::new()),
local,
config,
)
}
pub fn with_reputation(
client: ComputeClient,
directory: Arc<CapabilityDirectory>,
reputation: Arc<ReputationBook>,
local: NodeId,
config: SchedulerConfig,
) -> Self {
Self {
client,
directory,
reputation,
local,
config,
}
}
pub fn reputation(&self) -> Arc<ReputationBook> {
self.reputation.clone()
}
pub fn rank(&self, class: TaskClass) -> Vec<(NodeId, f64)> {
self.rank_for(class, None)
}
pub fn rank_for(&self, class: TaskClass, required_model: Option<&str>) -> Vec<(NodeId, f64)> {
let mut ranked: Vec<(NodeId, f64)> = self
.directory
.snapshot(self.config.max_vector_age)
.into_iter()
.filter(|v| v.node_id != self.local)
.filter(|v| required_model.is_none_or(|model| v.offers_model(model)))
.filter_map(|v| {
let base = score(&v, class, &self.config.weights)?;
let standing = self.reputation.factor(&v.node_id);
Some((
v.node_id,
base - (1.0 - standing) * self.config.weights.reputation_penalty,
))
})
.collect();
ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
ranked
}
pub async fn execute(&self, request: ExecuteRequest) -> Result<Delegated, ScheduleError> {
let order: Vec<NodeId> = self
.rank_for(request.class, request.required_model.as_deref())
.into_iter()
.map(|(n, _)| n)
.collect();
self.attempt_in_order(request, order).await
}
pub async fn execute_with_auction(
&self,
request: ExecuteRequest,
) -> Result<Delegated, ScheduleError> {
let ranked = self.rank_for(request.class, request.required_model.as_deref());
if ranked.is_empty() {
return Err(ScheduleError::NoCandidates);
}
let probe_count = self.config.auction_probe_count.clamp(1, ranked.len());
let class = request.class;
let bids =
futures::future::join_all(ranked[..probe_count].iter().map(|(node, _)| async move {
let reply = self
.client
.probe(*node, class, self.config.probe_timeout)
.await;
(*node, reply)
}))
.await;
let ranked_scores: std::collections::HashMap<NodeId, f64> =
ranked.iter().copied().collect();
let mut fresh: Vec<(NodeId, f64)> = bids
.into_iter()
.filter_map(|(node, reply)| match reply {
Ok(bid) if bid.accepts_class => {
let base = self
.directory
.get(&node, self.config.max_vector_age)
.and_then(|mut vector| {
vector.cpu_load_pct = bid.cpu_load_pct;
vector.ram_free_mb = bid.ram_free_mb;
vector.tasks_running = vector
.max_concurrent
.saturating_sub(bid.free_slots.min(u8::MAX as u32) as u8);
score(&vector, class, &self.config.weights)
})
.or_else(|| ranked_scores.get(&node).copied())?;
let standing = self.reputation.factor(&node);
Some((
node,
base - (1.0 - standing) * self.config.weights.reputation_penalty,
))
}
Ok(_) => None, Err(err) => {
debug!(executor = %node.fmt_short(), error = %err,
"compute auction: probe failed");
None
}
})
.collect();
fresh.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let order: Vec<NodeId> = if fresh.is_empty() {
ranked.into_iter().map(|(n, _)| n).collect()
} else {
fresh.into_iter().map(|(n, _)| n).collect()
};
self.attempt_in_order(request, order).await
}
pub async fn map(
&self,
requests: Vec<ExecuteRequest>,
) -> Vec<Result<Delegated, ScheduleError>> {
futures::future::join_all(requests.into_iter().enumerate().map(|(i, request)| {
let mut order: Vec<NodeId> = self
.rank_for(request.class, request.required_model.as_deref())
.into_iter()
.map(|(n, _)| n)
.collect();
if !order.is_empty() {
let shift = i % order.len();
order.rotate_left(shift);
}
self.attempt_in_order(request, order)
}))
.await
}
pub async fn execute_redundant(
&self,
request: ExecuteRequest,
k: usize,
) -> Result<RedundantOutcome, ScheduleError> {
let candidates: Vec<NodeId> = self
.rank_for(request.class, request.required_model.as_deref())
.into_iter()
.map(|(n, _)| n)
.take(k.max(1))
.collect();
if candidates.is_empty() {
return Err(ScheduleError::NoCandidates);
}
let runs = futures::future::join_all(candidates.iter().map(|node| {
let request = request.clone();
async move {
(
*node,
self.client
.execute_on(*node, request, self.config.attempt_timeout)
.await,
)
}
}))
.await;
let mut attempts: Vec<(NodeId, ComputeCallError)> = Vec::new();
let mut successes: Vec<(NodeId, super::protocol::CompletedTask)> = Vec::new();
for (node, result) in runs {
match result {
Ok(completed) => successes.push((node, completed)),
Err(err) => {
if matches!(
err,
ComputeCallError::Unreachable(_) | ComputeCallError::Timeout
) {
self.directory.remove(&node);
}
attempts.push((node, err));
}
}
}
if successes.is_empty() {
return Err(ScheduleError::AllAttemptsFailed { attempts });
}
let Some((winners, divergent)) = majority_by_output(&successes) else {
return Err(ScheduleError::Divergent);
};
for node in &winners {
self.reputation.reward(node);
}
for node in &divergent {
warn!(executor = %node.fmt_short(),
"compute redundant: divergent result, reputation penalized");
self.reputation.penalize(node);
}
let winner = winners[0];
let completed = successes
.iter()
.find(|(node, _)| *node == winner)
.map(|(_, completed)| completed.clone())
.expect("winner comes from successes");
Ok(RedundantOutcome {
delegated: Delegated {
executor: winner,
completed,
},
agreements: winners.len(),
responded: successes.len(),
divergent,
})
}
async fn attempt_in_order(
&self,
request: ExecuteRequest,
order: Vec<NodeId>,
) -> Result<Delegated, ScheduleError> {
if order.is_empty() {
return Err(ScheduleError::NoCandidates);
}
let mut attempts: Vec<(NodeId, ComputeCallError)> = Vec::new();
for node in order.into_iter().take(self.config.max_attempts.max(1)) {
debug!(task = %request.task_id, executor = %node.fmt_short(),
"compute: delegating to ranked candidate");
match self
.client
.execute_on(node, request.clone(), self.config.attempt_timeout)
.await
{
Ok(completed) => {
return Ok(Delegated {
executor: node,
completed,
});
}
Err(ComputeCallError::Task(error)) if !error.is_transient() => {
return Err(ScheduleError::TaskFailed {
executor: node,
error,
});
}
Err(err) => {
warn!(task = %request.task_id, executor = %node.fmt_short(),
error = %err, "compute: attempt failed, trying next candidate");
match &err {
ComputeCallError::Unreachable(_) | ComputeCallError::Timeout => {
self.directory.remove(&node);
}
_ => {}
}
attempts.push((node, err));
}
}
}
Err(ScheduleError::AllAttemptsFailed { attempts })
}
}
fn majority_by_output(
successes: &[(NodeId, super::protocol::CompletedTask)],
) -> Option<(Vec<NodeId>, Vec<NodeId>)> {
let mut groups: HashMap<[u8; 32], Vec<NodeId>> = HashMap::new();
for (node, completed) in successes {
groups
.entry(*blake3::hash(&completed.output).as_bytes())
.or_default()
.push(*node);
}
let best_size = groups.values().map(Vec::len).max()?;
let leaders: Vec<_> = groups.values().filter(|g| g.len() == best_size).collect();
if leaders.len() != 1 {
return None; }
let winners = leaders[0].clone();
let divergent = groups
.values()
.filter(|g| g.len() != best_size)
.flatten()
.copied()
.collect();
Some((winners, divergent))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compute::{CpuArch, ResourceLimits};
fn node(seed: u8) -> NodeId {
let mut bytes = [0u8; 32];
bytes[0] = seed;
for (i, b) in bytes.iter_mut().enumerate().skip(1) {
*b = seed.wrapping_add(i as u8);
}
iroh::SecretKey::from_bytes(&bytes).public()
}
fn vector(id: NodeId, cores: u16, load: u8, ram_free: u32) -> CapabilityVector {
CapabilityVector {
node_id: id,
cpu_cores: cores,
cpu_arch: CpuArch::X86_64,
ram_total_mb: ram_free * 2,
accelerators: vec![],
cpu_load_pct: load,
ram_free_mb: ram_free,
on_battery: false,
battery_pct: None,
tasks_running: 0,
max_concurrent: 4,
accepts: vec![TaskClass::General, TaskClass::Media],
nn_models: vec![],
issued_at: 0,
}
}
fn scheduler_with(directory: Arc<CapabilityDirectory>, local: NodeId) -> ComputeScheduler {
let endpoint = futures::executor::block_on(async {
iroh::endpoint::Endpoint::builder(iroh::endpoint::presets::Minimal)
.bind()
.await
});
let endpoint = endpoint.expect("bind test endpoint");
ComputeScheduler::new(
ComputeClient::new(endpoint),
directory,
local,
SchedulerConfig::default(),
)
}
#[test]
fn stronger_idle_node_outscores_busy_small_one() {
let w = ScoreWeights::default();
let strong = vector(node(1), 16, 10, 32_000);
let weak = vector(node(2), 4, 80, 4_000);
assert!(score(&strong, TaskClass::General, &w) > score(&weak, TaskClass::General, &w));
}
#[test]
fn battery_penalty_disqualifies_in_practice() {
let w = ScoreWeights::default();
let mut on_battery = vector(node(1), 16, 0, 32_000);
on_battery.on_battery = true;
let modest = vector(node(2), 2, 50, 2_000);
assert!(
score(&on_battery, TaskClass::General, &w) < score(&modest, TaskClass::General, &w)
);
}
#[test]
fn accelerator_bonus_applies_only_to_inference() {
let w = ScoreWeights::default();
let mut plain = vector(node(1), 8, 10, 8_000);
plain.accepts = vec![TaskClass::General, TaskClass::Inference];
let mut gpu = plain.clone();
gpu.node_id = node(2);
gpu.accelerators = vec![crate::compute::Accel::Gpu];
let bonus = score(&gpu, TaskClass::Inference, &w).unwrap()
- score(&plain, TaskClass::Inference, &w).unwrap();
assert!((bonus - w.accelerator_bonus).abs() < 1e-9);
let general_delta = score(&gpu, TaskClass::General, &w).unwrap()
- score(&plain, TaskClass::General, &w).unwrap();
assert!(general_delta.abs() < 1e-9);
}
#[tokio::test]
async fn bad_reputation_demotes_a_stronger_node() {
let local = node(9);
let directory = Arc::new(CapabilityDirectory::new());
directory.upsert(vector(node(1), 16, 10, 32_000)); directory.upsert(vector(node(2), 4, 50, 4_000)); let scheduler = scheduler_with(directory, local);
assert_eq!(scheduler.rank(TaskClass::General)[0].0, node(1));
scheduler.reputation().penalize(&node(1));
assert_eq!(
scheduler.rank(TaskClass::General)[0].0,
node(2),
"the known liar must rank below the honest weak node"
);
}
#[tokio::test]
async fn required_model_constrains_the_ranking() {
let local = node(9);
let directory = Arc::new(CapabilityDirectory::new());
let mut strong = vector(node(1), 16, 10, 32_000);
strong.accepts.push(TaskClass::Inference);
let mut weak_with_model = vector(node(2), 4, 50, 4_000);
weak_with_model.accepts.push(TaskClass::Inference);
weak_with_model.nn_models = vec!["whisper-tiny".into()];
directory.upsert(strong);
directory.upsert(weak_with_model);
let scheduler = scheduler_with(directory, local);
assert_eq!(scheduler.rank(TaskClass::Inference)[0].0, node(1));
let ranked = scheduler.rank_for(TaskClass::Inference, Some("whisper-tiny"));
assert_eq!(
ranked.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
vec![node(2)],
"model affinity is a hard constraint"
);
assert!(
scheduler
.rank_for(TaskClass::Inference, Some("nao-existe"))
.is_empty()
);
}
#[test]
fn majority_grouping_picks_winner_and_flags_divergents() {
let completed = |bytes: &[u8]| crate::compute::CompletedTask {
output: bytes.to_vec(),
metrics: crate::compute::ExecMetrics {
fuel_consumed: 1,
duration_ms: 1,
peak_memory_bytes: 0,
},
};
let successes = vec![
(node(1), completed(b"correct")),
(node(2), completed(b"correct")),
(node(3), completed(b"forged")),
];
let (winners, divergent) = majority_by_output(&successes).expect("majority");
assert_eq!(winners.len(), 2);
assert_eq!(divergent, vec![node(3)]);
let tied = vec![(node(1), completed(b"a")), (node(2), completed(b"b"))];
assert!(majority_by_output(&tied).is_none());
let single = vec![(node(1), completed(b"only"))];
let (winners, divergent) = majority_by_output(&single).expect("majority");
assert_eq!(winners, vec![node(1)]);
assert!(divergent.is_empty());
}
#[test]
fn non_candidates_score_none() {
let w = ScoreWeights::default();
let mut v = vector(node(1), 8, 10, 8_000);
assert!(
score(&v, TaskClass::Inference, &w).is_none(),
"class not accepted"
);
v.tasks_running = v.max_concurrent;
assert!(score(&v, TaskClass::General, &w).is_none(), "no free slot");
}
#[tokio::test]
async fn rank_orders_by_score_and_excludes_local_and_stale() {
let local = node(9);
let directory = Arc::new(CapabilityDirectory::new());
directory.upsert(vector(node(1), 16, 10, 32_000)); directory.upsert(vector(node(2), 4, 80, 4_000)); directory.upsert(vector(local, 64, 0, 128_000));
let scheduler = scheduler_with(directory.clone(), local);
let ranked = scheduler.rank(TaskClass::General);
assert_eq!(
ranked.iter().map(|(n, _)| *n).collect::<Vec<_>>(),
vec![node(1), node(2)]
);
directory.remove(&node(1));
let ranked = scheduler.rank(TaskClass::General);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].0, node(2));
}
#[tokio::test]
async fn empty_directory_yields_no_candidates() {
let local = node(9);
let scheduler = scheduler_with(Arc::new(CapabilityDirectory::new()), local);
let request = ExecuteRequest {
task_id: uuid::Uuid::new_v4(),
wasm_hash: iroh_blobs::Hash::new(b"x"),
entrypoint: "gdb_run".into(),
class: TaskClass::General,
limits: ResourceLimits::default(),
input: vec![],
required_model: None,
};
assert!(matches!(
scheduler.execute(request).await,
Err(ScheduleError::NoCandidates)
));
}
}